Track inventory at the instance level, not by product
Build and push image / build (push) Successful in 46s

The products table conflated catalog ("kind of thing you scan") with
instance ("this jar I bought") — splitting it lets us record every
purchase as its own asset and autofill brand/shop/price/THC from the
last instance when scanning a known SKU.

- products: sku + strain + name + type + kind (catalog only)
- inventory_items: physical jars with short-UUID asset ids, per-batch
  brand/shop/bin/price/cannabinoids/weight, audits, lifecycle
- audits now key on inventory_id; strains lose brand_id and type
- migration: rename existing products/audits/strains to *_legacy on
  first boot so users keep historical reference, fresh start otherwise
- two-step add flow: scan SKU → select/create product → instance
  details (autofilled from last instance) → generated asset id shown
- ScanField matches asset id first, falls back to SKU
- inventory list defaults flat, "By product" toggle groups instances

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 05:59:46 -04:00
parent 1abfda7989
commit 02dc6e523f
28 changed files with 2315 additions and 1355 deletions
+142 -320
View File
@@ -1,375 +1,197 @@
import { Router } from "express";
import { db, nextId, randomSku } from "../db.js";
import { db, nextId } from "../db.js";
export const productsRouter: Router = Router();
type CreateBody = {
sku: string;
name: string;
brandId: string;
shopId: string;
binId: string;
type: string;
kind: "bulk" | "discrete";
weight?: number;
countOriginal?: number;
unitWeight?: number;
price: number;
thc: number;
cbd: number;
totalCannabinoids: number;
purchaseDate: string;
sku?: string;
assetTag?: string;
strainId?: string | null;
strainName?: string;
defaultThc?: number;
defaultCbd?: number;
defaultTotalCannabinoids?: number;
};
productsRouter.post("/products", (req, res) => {
const body = req.body as CreateBody;
if (!body.name) return res.status(400).json({ error: "name required" });
if (!body.sku?.trim()) return res.status(400).json({ error: "sku required" });
if (!body.name?.trim()) return res.status(400).json({ error: "name 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" });
}
const id = nextId("prd", "products");
const sku = body.sku && body.sku.trim() ? body.sku.trim() : randomSku();
const isDiscrete = body.kind === "discrete";
const trimmedName = body.name.trim();
const brandId = body.brandId ?? null;
const sku = body.sku.trim();
const name = body.name.trim();
const todayIso = new Date().toISOString().slice(0, 10);
const tx = db.transaction(() => {
// Find-or-create the strain (case-insensitive name match scoped to brand+type).
const found = db
.prepare<
[string, string | null, string | null, string],
{ id: string }
>(
`SELECT id FROM strains
WHERE name = ? COLLATE NOCASE
AND (brand_id IS ? OR brand_id = ?)
AND type = ?`,
)
.get(trimmedName, brandId, brandId, body.type);
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 });
}
let strainId: string;
if (found) {
strainId = found.id;
} else {
strainId = nextId("str", "strains");
db.prepare(`
INSERT INTO strains (
id, name, brand_id, type,
default_thc, default_cbd, default_total_cannabinoids, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
strainId,
trimmedName,
brandId,
body.type,
body.thc,
body.cbd,
body.totalCannabinoids,
todayIso,
);
}
const id = nextId("pdt", "products");
db.prepare(`
INSERT INTO products (
id, sku, asset_tag, name, brand_id, shop_id, bin_id,
type, kind, weight, last_audit_weight,
count_original, count_last_audit, unit_weight,
price, thc, cbd, total_cannabinoids,
purchase_date, status, strain_id
) VALUES (
@id, @sku, @assetTag, @name, @brandId, @shopId, @binId,
@type, @kind, @weight, @lastAuditWeight,
@countOriginal, @countLastAudit, @unitWeight,
@price, @thc, @cbd, @totalCannabinoids,
@purchaseDate, 'active', @strainId
)
`).run({
id,
sku,
assetTag: body.assetTag?.trim() ? body.assetTag.trim() : null,
name: trimmedName,
brandId,
shopId: body.shopId,
binId: body.binId,
type: body.type,
kind: body.kind,
weight: isDiscrete ? 0 : body.weight ?? 0,
lastAuditWeight: isDiscrete ? null : body.weight ?? 0,
countOriginal: isDiscrete ? body.countOriginal ?? 0 : 0,
countLastAudit: isDiscrete ? body.countOriginal ?? 0 : null,
unitWeight: isDiscrete ? body.unitWeight ?? 0 : 0,
price: body.price,
thc: body.thc,
cbd: body.cbd,
totalCannabinoids: body.totalCannabinoids,
purchaseDate: body.purchaseDate,
strainId,
try {
const tx = db.transaction(() => {
let strainId = body.strainId ?? null;
const strainName = body.strainName?.trim() || name;
if (!strainId && strainName) {
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, name, type, kind, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(id, sku, strainId, name, body.type, body.kind, todayIso);
});
});
tx();
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<{
name: string;
brandId: string | null;
shopId: string | null;
binId: string | null;
assetTag: string | null;
weight: number;
countOriginal: number;
unitWeight: number;
price: number;
thc: number;
cbd: number;
totalCannabinoids: number;
purchaseDate: string;
type: string;
kind: "bulk" | "discrete";
strainId: string | null;
}>;
productsRouter.patch("/products/:id", (req, res) => {
const { id } = req.params;
const body = req.body as UpdateBody;
type Row = {
id: string;
name: string;
brand_id: string | null;
shop_id: string | null;
bin_id: string | null;
asset_tag: string | null;
type: string;
kind: "bulk" | "discrete";
weight: number;
last_audit_weight: number | null;
count_original: number;
count_last_audit: number | null;
unit_weight: number;
price: number;
thc: number;
cbd: number;
total_cannabinoids: number;
purchase_date: string;
strain_id: string | null;
};
const existing = db
.prepare<[string], Row>(
`SELECT id, name, brand_id, shop_id, bin_id, asset_tag, type, kind,
weight, last_audit_weight, count_original, count_last_audit, unit_weight,
price, thc, cbd, total_cannabinoids, purchase_date, strain_id
FROM products WHERE id = ?`,
.prepare<
[string],
{ id: string; name: string; type: string; kind: string; strain_id: string | null }
>(
`SELECT id, name, type, kind, strain_id FROM products WHERE id = ?`,
)
.get(id);
if (!existing) return res.status(404).json({ error: "product not found" });
const auditCount = db
.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM audits WHERE product_id = ?")
.get(id)!.n;
const trimOrUndef = (v: unknown) =>
typeof v === "string" ? v.trim() : v;
const nextName =
body.name !== undefined && (body.name as string).trim()
? (body.name as string).trim()
: existing.name;
const nextBrandId =
body.brandId === undefined ? existing.brand_id : body.brandId || null;
const nextShopId =
body.shopId === undefined ? existing.shop_id : body.shopId || null;
const nextBinId =
body.binId === undefined ? existing.bin_id : body.binId || null;
const nextAssetTag =
body.assetTag === undefined
? existing.asset_tag
: (trimOrUndef(body.assetTag) as string | null) || null;
const nextPrice =
Number.isFinite(body.price) && (body.price as number) >= 0
? (body.price as number)
: existing.price;
const nextPurchaseDate =
typeof body.purchaseDate === "string" && body.purchaseDate.trim()
? body.purchaseDate.trim()
: existing.purchase_date;
const nextThc =
Number.isFinite(body.thc) ? (body.thc as number) : existing.thc;
const nextCbd =
Number.isFinite(body.cbd) ? (body.cbd as number) : existing.cbd;
const nextTotalCanna = Number.isFinite(body.totalCannabinoids)
? (body.totalCannabinoids as number)
: existing.total_cannabinoids;
typeof body.name === "string" && body.name.trim() ? body.name.trim() : existing.name;
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 nextStrainId =
body.strainId === undefined ? existing.strain_id : body.strainId;
const isDiscrete = existing.kind === "discrete";
const nextWeight =
!isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0
? (body.weight as number)
: existing.weight;
const nextCountOriginal =
isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0
? Math.floor(body.countOriginal as number)
: existing.count_original;
const nextUnitWeight =
isDiscrete && Number.isFinite(body.unitWeight) && (body.unitWeight as number) >= 0
? (body.unitWeight as number)
: existing.unit_weight;
// If no audits exist yet, keep the "last audit" mirror in lock-step with
// the original size — otherwise the next audit's prev_value would be stale.
const nextLastAuditWeight =
!isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight;
const nextCountLastAudit =
isDiscrete && auditCount === 0 ? nextCountOriginal : existing.count_last_audit;
const tx = db.transaction(() => {
let nextStrainId = existing.strain_id;
const nameChanged = nextName !== existing.name;
const brandChanged = nextBrandId !== existing.brand_id;
if (nameChanged || brandChanged) {
const todayIso = new Date().toISOString().slice(0, 10);
const found = db
.prepare<
[string, string | null, string | null, string],
{ id: string }
>(
`SELECT id FROM strains
WHERE name = ? COLLATE NOCASE
AND (brand_id IS ? OR brand_id = ?)
AND type = ?`,
)
.get(nextName, nextBrandId, nextBrandId, existing.type);
if (found) {
nextStrainId = found.id;
} else {
nextStrainId = nextId("str", "strains");
db.prepare(`
INSERT INTO strains (
id, name, brand_id, type,
default_thc, default_cbd, default_total_cannabinoids, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
nextStrainId,
nextName,
nextBrandId,
existing.type,
nextThc,
nextCbd,
nextTotalCanna,
todayIso,
);
}
}
db.prepare(`
UPDATE products SET
name = @name,
brand_id = @brandId,
shop_id = @shopId,
bin_id = @binId,
asset_tag = @assetTag,
weight = @weight,
last_audit_weight = @lastAuditWeight,
count_original = @countOriginal,
count_last_audit = @countLastAudit,
unit_weight = @unitWeight,
price = @price,
thc = @thc,
cbd = @cbd,
total_cannabinoids = @totalCannabinoids,
purchase_date = @purchaseDate,
strain_id = @strainId
WHERE id = @id
`).run({
id,
name: nextName,
brandId: nextBrandId,
shopId: nextShopId,
binId: nextBinId,
assetTag: nextAssetTag,
weight: nextWeight,
lastAuditWeight: nextLastAuditWeight,
countOriginal: nextCountOriginal,
countLastAudit: nextCountLastAudit,
unitWeight: nextUnitWeight,
price: nextPrice,
thc: nextThc,
cbd: nextCbd,
totalCannabinoids: nextTotalCanna,
purchaseDate: nextPurchaseDate,
strainId: nextStrainId,
});
});
tx();
db.prepare(
`UPDATE products SET name = ?, type = ?, kind = ?, strain_id = ? WHERE id = ?`,
).run(nextName, nextType, nextKind, nextStrainId, id);
res.json({ ok: true });
});
productsRouter.post("/products/:id/finish", (req, res) => {
productsRouter.delete("/products/:id", (req, res) => {
const { id } = req.params;
const { date, rating, notes } = req.body as { date: string; rating?: number; notes?: string };
const result = db
.prepare(
"UPDATE products SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL WHERE id = ? AND status = 'active'",
const inUse = db
.prepare<[string], { n: number }>(
`SELECT COUNT(*) AS n FROM inventory_items WHERE product_id = ?`,
)
.run(date, rating ?? null, notes ?? null, id);
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
.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 });
});
productsRouter.post("/products/:id/gone", (req, res) => {
// Strain management — kept here since strains are catalog cousins of products.
productsRouter.patch("/strains/:id", (req, res) => {
const { id } = req.params;
const { date, reason, notes } = req.body as { date: string; reason: string; notes?: string };
const combinedNotes = notes ? `${reason}: ${notes}` : reason;
const tx = db.transaction(() => {
const result = db
.prepare(
"UPDATE products SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL WHERE id = ? AND status = 'active'",
)
.run(date, combinedNotes, id);
if (result.changes === 0) throw new Error("not found");
db.prepare(
"INSERT INTO audits (product_id, date, mode, value, prev_value, confirmed_by) VALUES (?, ?, 'presence', 0, NULL, 'lost')",
).run(id, date);
});
try {
tx();
res.json({ ok: true });
} catch {
res.status(404).json({ error: "not found or not active" });
}
});
const body = req.body as Partial<{
name: string;
defaultThc: number | null;
defaultCbd: number | null;
defaultTotalCannabinoids: number | null;
notes: string | null;
}>;
productsRouter.post("/products/:id/audit", (req, res) => {
const { id } = req.params;
const { date, mode, value, confirmedBy } = req.body as {
date: string;
mode: "weigh" | "estimate" | "presence";
value: number;
confirmedBy?: string;
};
const product = db
.prepare<[string], { kind: string; weight: number; last_audit_weight: number | null; count_original: number; count_last_audit: number | null }>(
"SELECT kind, weight, last_audit_weight, count_original, count_last_audit FROM products WHERE id = ?",
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 (!product) return res.status(404).json({ error: "not found" });
if (!existing) return res.status(404).json({ error: "strain not found" });
const prev =
product.kind === "discrete"
? product.count_last_audit ?? product.count_original
: product.last_audit_weight ?? product.weight;
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;
const tx = db.transaction(() => {
try {
db.prepare(
"INSERT INTO audits (product_id, date, mode, value, prev_value, confirmed_by) VALUES (?, ?, ?, ?, ?, ?)",
).run(id, date, mode, value, prev, confirmedBy ?? null);
if (product.kind === "discrete") {
db.prepare("UPDATE products SET count_last_audit = ? WHERE id = ?").run(value, id);
} else {
db.prepare("UPDATE products SET last_audit_weight = ? WHERE id = ?").run(value, id);
`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" });
}
});
tx();
res.json({ ok: true });
throw err;
}
});