82a72805cf
Build and push image / build (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
235 lines
7.5 KiB
TypeScript
235 lines
7.5 KiB
TypeScript
import { Router } from "express";
|
|
import { db, nextId } from "../db.js";
|
|
|
|
export const productsRouter: Router = Router();
|
|
|
|
type CreateBody = {
|
|
sku: string;
|
|
// strain identity: either link to an existing strain via id, or supply a
|
|
// name and the server creates / matches one. Strain.name doubles as the
|
|
// product's display name.
|
|
strainId?: string | null;
|
|
strainName?: string;
|
|
brandId?: string | null;
|
|
type: string;
|
|
kind: "bulk" | "discrete";
|
|
defaultThc?: number;
|
|
defaultCbd?: number;
|
|
defaultTotalCannabinoids?: number;
|
|
};
|
|
|
|
productsRouter.post("/products", (req, res) => {
|
|
const body = req.body as CreateBody;
|
|
if (!body.sku?.trim()) return res.status(400).json({ error: "sku required" });
|
|
if (!body.type) return res.status(400).json({ error: "type required" });
|
|
if (body.kind !== "bulk" && body.kind !== "discrete") {
|
|
return res.status(400).json({ error: "kind must be bulk or discrete" });
|
|
}
|
|
if (!body.strainId && !body.strainName?.trim()) {
|
|
return res.status(400).json({ error: "strainId or strainName required" });
|
|
}
|
|
|
|
const sku = body.sku.trim();
|
|
const todayIso = new Date().toISOString().slice(0, 10);
|
|
|
|
const existingSku = db
|
|
.prepare<[string], { id: string }>("SELECT id FROM products WHERE sku = ?")
|
|
.get(sku);
|
|
if (existingSku) {
|
|
return res.status(409).json({ error: "sku already exists", id: existingSku.id });
|
|
}
|
|
|
|
const id = nextId("pdt", "products");
|
|
|
|
try {
|
|
const tx = db.transaction(() => {
|
|
let strainId = body.strainId ?? null;
|
|
|
|
if (!strainId) {
|
|
const strainName = body.strainName!.trim();
|
|
const found = db
|
|
.prepare<[string], { id: string }>(
|
|
`SELECT id FROM strains WHERE name = ? COLLATE NOCASE`,
|
|
)
|
|
.get(strainName);
|
|
if (found) {
|
|
strainId = found.id;
|
|
} else {
|
|
strainId = nextId("str", "strains");
|
|
db.prepare(
|
|
`INSERT INTO strains (
|
|
id, name,
|
|
default_thc, default_cbd, default_total_cannabinoids,
|
|
created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
strainId,
|
|
strainName,
|
|
body.defaultThc ?? null,
|
|
body.defaultCbd ?? null,
|
|
body.defaultTotalCannabinoids ?? null,
|
|
todayIso,
|
|
);
|
|
}
|
|
}
|
|
|
|
db.prepare(
|
|
`INSERT INTO products (id, sku, strain_id, brand_id, type, kind, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(id, sku, strainId, body.brandId ?? null, body.type, body.kind, todayIso);
|
|
});
|
|
tx();
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "";
|
|
if (msg.includes("UNIQUE")) {
|
|
return res.status(409).json({ error: "sku already exists" });
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
res.json({ id });
|
|
});
|
|
|
|
type UpdateBody = Partial<{
|
|
sku: string;
|
|
type: string;
|
|
kind: "bulk" | "discrete";
|
|
strainId: string | null;
|
|
strainName: string;
|
|
brandId: string | null;
|
|
}>;
|
|
|
|
productsRouter.patch("/products/:id", (req, res) => {
|
|
const { id } = req.params;
|
|
const body = req.body as UpdateBody;
|
|
|
|
const existing = db
|
|
.prepare<
|
|
[string],
|
|
{ id: string; sku: string; type: string; kind: string; strain_id: string; brand_id: string | null }
|
|
>(
|
|
`SELECT id, sku, type, kind, strain_id, brand_id FROM products WHERE id = ?`,
|
|
)
|
|
.get(id);
|
|
if (!existing) return res.status(404).json({ error: "product not found" });
|
|
|
|
const nextSku = typeof body.sku === "string" && body.sku.trim() ? body.sku.trim() : existing.sku;
|
|
if (nextSku !== existing.sku) {
|
|
const duplicate = db
|
|
.prepare<[string, string], { id: string }>("SELECT id FROM products WHERE sku = ? AND id != ?")
|
|
.get(nextSku, id);
|
|
if (duplicate) return res.status(409).json({ error: "sku already exists" });
|
|
}
|
|
|
|
const nextType = typeof body.type === "string" && body.type ? body.type : existing.type;
|
|
const nextKind: "bulk" | "discrete" =
|
|
body.kind === "bulk" || body.kind === "discrete"
|
|
? body.kind
|
|
: (existing.kind as "bulk" | "discrete");
|
|
const nextBrandId =
|
|
body.brandId === undefined ? existing.brand_id : body.brandId;
|
|
|
|
// Strain handling: prefer explicit strainId; else if strainName given,
|
|
// match-by-name (case-insensitive) or create a new strain.
|
|
let nextStrainId = existing.strain_id;
|
|
const todayIso = new Date().toISOString().slice(0, 10);
|
|
if (body.strainId !== undefined && body.strainId) {
|
|
nextStrainId = body.strainId;
|
|
} else if (typeof body.strainName === "string" && body.strainName.trim()) {
|
|
const name = body.strainName.trim();
|
|
const found = db
|
|
.prepare<[string], { id: string }>(
|
|
`SELECT id FROM strains WHERE name = ? COLLATE NOCASE`,
|
|
)
|
|
.get(name);
|
|
if (found) {
|
|
nextStrainId = found.id;
|
|
} else {
|
|
nextStrainId = nextId("str", "strains");
|
|
db.prepare(
|
|
`INSERT INTO strains (id, name, created_at) VALUES (?, ?, ?)`,
|
|
).run(nextStrainId, name, todayIso);
|
|
}
|
|
}
|
|
|
|
db.prepare(
|
|
`UPDATE products SET sku = ?, type = ?, kind = ?, strain_id = ?, brand_id = ? WHERE id = ?`,
|
|
).run(nextSku, nextType, nextKind, nextStrainId, nextBrandId, id);
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
productsRouter.delete("/products/:id", (req, res) => {
|
|
const { id } = req.params;
|
|
const inUse = db
|
|
.prepare<[string], { n: number }>(
|
|
`SELECT COUNT(*) AS n FROM inventory_items WHERE product_id = ?`,
|
|
)
|
|
.get(id)!.n;
|
|
if (inUse > 0) {
|
|
return res
|
|
.status(409)
|
|
.json({ error: `product has ${inUse} inventory item${inUse === 1 ? "" : "s"}` });
|
|
}
|
|
const result = db.prepare(`DELETE FROM products WHERE id = ?`).run(id);
|
|
if (result.changes === 0) return res.status(404).json({ error: "product not found" });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Strain management — kept here since strains are catalog cousins of products.
|
|
productsRouter.patch("/strains/:id", (req, res) => {
|
|
const { id } = req.params;
|
|
const body = req.body as Partial<{
|
|
name: string;
|
|
defaultThc: number | null;
|
|
defaultCbd: number | null;
|
|
defaultTotalCannabinoids: number | null;
|
|
notes: string | null;
|
|
}>;
|
|
|
|
const existing = db
|
|
.prepare<
|
|
[string],
|
|
{
|
|
id: string;
|
|
name: string;
|
|
default_thc: number | null;
|
|
default_cbd: number | null;
|
|
default_total_cannabinoids: number | null;
|
|
notes: string | null;
|
|
}
|
|
>(
|
|
`SELECT id, name, default_thc, default_cbd, default_total_cannabinoids, notes
|
|
FROM strains WHERE id = ?`,
|
|
)
|
|
.get(id);
|
|
if (!existing) return res.status(404).json({ error: "strain not found" });
|
|
|
|
const nextName =
|
|
typeof body.name === "string" && body.name.trim() ? body.name.trim() : existing.name;
|
|
const nextThc =
|
|
body.defaultThc === undefined ? existing.default_thc : body.defaultThc;
|
|
const nextCbd =
|
|
body.defaultCbd === undefined ? existing.default_cbd : body.defaultCbd;
|
|
const nextTotal =
|
|
body.defaultTotalCannabinoids === undefined
|
|
? existing.default_total_cannabinoids
|
|
: body.defaultTotalCannabinoids;
|
|
const nextNotes = body.notes === undefined ? existing.notes : body.notes;
|
|
|
|
try {
|
|
db.prepare(
|
|
`UPDATE strains SET
|
|
name = ?, default_thc = ?, default_cbd = ?, default_total_cannabinoids = ?, notes = ?
|
|
WHERE id = ?`,
|
|
).run(nextName, nextThc, nextCbd, nextTotal, nextNotes, id);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "";
|
|
if (msg.includes("UNIQUE")) {
|
|
return res.status(409).json({ error: "another strain already uses that name" });
|
|
}
|
|
throw err;
|
|
}
|
|
});
|