User-supplied asset ids; brand on product; strain is the name
Build and push image / build (push) Successful in 48s

Four UX changes after using the rework for a bit:

1. Asset ids are 6-digit numbers from a roll of physical labels — server
   no longer generates them. POST /api/inventory requires assetId; the
   add-inventory form has a digits-only input that auto-focuses on entry.
2. Strain and product name are the same thing. Drop products.name; the
   strain's name supplies the display. Product creation just asks for
   "Name (strain)" and matches/creates a strain by that name.
3. Brand moves from inventory_items to products. SKUs are brand-specific,
   so all instances of a product share the brand. Brand selector lives
   on the product create/edit form, not the per-instance form.
4. Scanning an unknown SKU on the add-inventory step now opens the
   create-product subform with the SKU prefilled — one less click.

Migration: detect prior shape (products.name column present) and rename
products/inventory_items/audits to *_v1 archives, recreate empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 18:17:12 -04:00
parent 02dc6e523f
commit 80034b47c5
15 changed files with 380 additions and 256 deletions
+38 -28
View File
@@ -12,15 +12,16 @@ db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
archiveLegacyIfPresent();
archiveV1IfPresent();
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
db.exec(schema);
// One-shot migration: the old schema put per-instance fields (weight, bin_id,
// etc.) directly on `products`. The new schema splits products (catalog) from
// inventory_items (instance). When we detect the old shape, rename the live
// tables out of the way so the new CREATE TABLE IF NOT EXISTS statements can
// build the empty new ones. Legacy tables stay queryable for reference.
// One-shot migration: the original schema put per-instance fields (weight,
// bin_id, etc.) directly on `products`. The split schema separates products
// (catalog) from inventory_items (instance). When we detect the old shape,
// rename the live tables out of the way so the new CREATE TABLE IF NOT EXISTS
// statements can build the empty new ones. Legacy tables stay queryable.
function archiveLegacyIfPresent(): void {
const productCols = db
.prepare(`PRAGMA table_info(products)`)
@@ -43,6 +44,34 @@ function archiveLegacyIfPresent(): void {
`);
}
// Second-pass migration: between v1 (split-products) and the current shape we
// (a) move brand from instance to product, (b) drop product.name in favor of
// strain.name as the canonical display, and (c) require user-supplied 6-digit
// asset ids. Detect by `products` having a `name` column. Archive to *_v1
// and let the schema rebuild the empty current shape.
function archiveV1IfPresent(): void {
const productCols = db
.prepare(`PRAGMA table_info(products)`)
.all() as { name: string }[];
const looksLikeV1 =
productCols.some((c) => c.name === "name") &&
!productCols.some((c) => c.name === "weight");
if (!looksLikeV1) return;
const v1Exists = db
.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name='products_v1'`,
)
.get();
if (v1Exists) return;
db.exec(`
ALTER TABLE products RENAME TO products_v1;
ALTER TABLE inventory_items RENAME TO inventory_items_v1;
ALTER TABLE audits RENAME TO audits_v1;
`);
}
const PAD: Record<string, number> = {
prd: 4,
pdt: 4,
@@ -65,26 +94,7 @@ export function nextId(prefix: string, table: string): string {
return `${prefix}-${n}`;
}
// Crockford base32 alphabet — drops I, L, O, U so labels are unambiguous when
// read off a sticker. 32^6 ≈ 1B addresses, so collisions on any reasonable
// inventory are vanishingly rare; we still loop just to be safe.
const ASSET_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
export function generateAssetId(): string {
for (let attempt = 0; attempt < 16; attempt++) {
let id = "";
for (let i = 0; i < 6; i++) {
id += ASSET_ALPHABET[Math.floor(Math.random() * ASSET_ALPHABET.length)];
}
const taken = db
.prepare<[string], { id: string }>(
`SELECT id FROM inventory_items WHERE asset_id = ?`,
)
.get(id);
if (!taken) return id;
}
throw new Error("could not generate a unique asset id after 16 attempts");
}
export function randomSku(): string {
return "SKU-" + Math.random().toString(36).slice(2, 8).toUpperCase();
}
// Asset ids are 6-digit strings printed on a roll of physical asset tags
// the user owns. The system never generates them; it just enforces format
// and uniqueness at insert time.
export const ASSET_ID_RE = /^\d{6}$/;
+3 -5
View File
@@ -6,8 +6,8 @@ export const bootstrapRouter: Router = Router();
type ProductRow = {
id: string;
sku: string;
strain_id: string | null;
name: string;
strain_id: string;
brand_id: string | null;
type: string;
kind: string;
created_at: string;
@@ -17,7 +17,6 @@ type InventoryRow = {
id: string;
asset_id: string;
product_id: string;
brand_id: string | null;
shop_id: string | null;
bin_id: string | null;
price: number;
@@ -84,7 +83,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
id: p.id,
sku: p.sku,
strainId: p.strain_id,
name: p.name,
brandId: p.brand_id,
type: p.type,
kind: p.kind,
createdAt: p.created_at,
@@ -94,7 +93,6 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
id: i.id,
assetId: i.asset_id,
productId: i.product_id,
brandId: i.brand_id,
shopId: i.shop_id,
binId: i.bin_id,
price: i.price,
+3 -3
View File
@@ -35,12 +35,12 @@ catalogRouter.patch("/brands/:id", (req, res) => {
}
});
// Deleting a brand unparents any inventory items that reference it
// (brand_id → NULL), so users never lose inventory when reorganizing.
// Deleting a brand unparents any products that reference it
// (brand_id → NULL on products), so users never lose inventory.
catalogRouter.delete("/brands/:id", (req, res) => {
const { id } = req.params;
const tx = db.transaction(() => {
db.prepare("UPDATE inventory_items SET brand_id = NULL WHERE brand_id = ?").run(id);
db.prepare("UPDATE products SET brand_id = NULL WHERE brand_id = ?").run(id);
const result = db.prepare("DELETE FROM brands WHERE id = ?").run(id);
if (result.changes === 0) throw new Error("not found");
});
+17 -13
View File
@@ -1,11 +1,11 @@
import { Router } from "express";
import { db, generateAssetId, nextId } from "../db.js";
import { ASSET_ID_RE, db, nextId } from "../db.js";
export const inventoryRouter: Router = Router();
type CreateBody = {
assetId: string;
productId: string;
brandId?: string | null;
shopId?: string | null;
binId?: string | null;
price: number;
@@ -25,6 +25,18 @@ inventoryRouter.post("/inventory", (req, res) => {
if (!Number.isFinite(body.price) || body.price < 0) {
return res.status(400).json({ error: "price required" });
}
const assetId = (body.assetId ?? "").trim();
if (!ASSET_ID_RE.test(assetId)) {
return res.status(400).json({ error: "assetId must be exactly 6 digits" });
}
const taken = db
.prepare<[string], { id: string }>(
`SELECT id FROM inventory_items WHERE asset_id = ?`,
)
.get(assetId);
if (taken) {
return res.status(409).json({ error: "asset id already in use", id: taken.id });
}
const product = db
.prepare<[string], { id: string; kind: string }>(
@@ -35,19 +47,18 @@ inventoryRouter.post("/inventory", (req, res) => {
const isDiscrete = product.kind === "discrete";
const id = nextId("inv", "inventory_items");
const assetId = generateAssetId();
db.prepare(
`INSERT INTO inventory_items (
id, asset_id, product_id,
brand_id, shop_id, bin_id,
shop_id, bin_id,
price, thc, cbd, total_cannabinoids,
weight, last_audit_weight,
count_original, count_last_audit, unit_weight,
purchase_date, status
) VALUES (
@id, @assetId, @productId,
@brandId, @shopId, @binId,
@shopId, @binId,
@price, @thc, @cbd, @totalCannabinoids,
@weight, @lastAuditWeight,
@countOriginal, @countLastAudit, @unitWeight,
@@ -57,7 +68,6 @@ inventoryRouter.post("/inventory", (req, res) => {
id,
assetId,
productId: body.productId,
brandId: body.brandId ?? null,
shopId: body.shopId ?? null,
binId: body.binId ?? null,
price: body.price,
@@ -76,7 +86,6 @@ inventoryRouter.post("/inventory", (req, res) => {
});
type UpdateBody = Partial<{
brandId: string | null;
shopId: string | null;
binId: string | null;
price: number;
@@ -95,7 +104,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
type Row = {
id: string;
brand_id: string | null;
shop_id: string | null;
bin_id: string | null;
product_id: string;
@@ -113,7 +121,7 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
const existing = db
.prepare<[string], Row>(
`SELECT id, brand_id, shop_id, bin_id, product_id, price, thc, cbd,
`SELECT id, shop_id, bin_id, product_id, price, thc, cbd,
total_cannabinoids, weight, last_audit_weight, count_original,
count_last_audit, unit_weight, purchase_date
FROM inventory_items WHERE id = ?`,
@@ -132,8 +140,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
)
.get(id)!.n;
const nextBrandId =
body.brandId === undefined ? existing.brand_id : body.brandId || null;
const nextShopId =
body.shopId === undefined ? existing.shop_id : body.shopId || null;
const nextBinId =
@@ -174,7 +180,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
db.prepare(
`UPDATE inventory_items SET
brand_id = @brandId,
shop_id = @shopId,
bin_id = @binId,
price = @price,
@@ -190,7 +195,6 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
WHERE id = @id`,
).run({
id,
brandId: nextBrandId,
shopId: nextShopId,
binId: nextBinId,
price: nextPrice,
+47 -19
View File
@@ -5,11 +5,14 @@ export const productsRouter: Router = Router();
type CreateBody = {
sku: string;
name: string;
type: string;
kind: "bulk" | "discrete";
// 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;
@@ -18,14 +21,15 @@ type CreateBody = {
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.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" });
}
if (!body.strainId && !body.strainName?.trim()) {
return res.status(400).json({ error: "strainId or strainName required" });
}
const sku = body.sku.trim();
const name = body.name.trim();
const todayIso = new Date().toISOString().slice(0, 10);
const existingSku = db
@@ -40,9 +44,9 @@ productsRouter.post("/products", (req, res) => {
try {
const tx = db.transaction(() => {
let strainId = body.strainId ?? null;
const strainName = body.strainName?.trim() || name;
if (!strainId && strainName) {
if (!strainId) {
const strainName = body.strainName!.trim();
const found = db
.prepare<[string], { id: string }>(
`SELECT id FROM strains WHERE name = ? COLLATE NOCASE`,
@@ -70,9 +74,9 @@ productsRouter.post("/products", (req, res) => {
}
db.prepare(
`INSERT INTO products (id, sku, strain_id, name, type, kind, created_at)
`INSERT INTO products (id, sku, strain_id, brand_id, type, kind, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(id, sku, strainId, name, body.type, body.kind, todayIso);
).run(id, sku, strainId, body.brandId ?? null, body.type, body.kind, todayIso);
});
tx();
} catch (err) {
@@ -87,10 +91,11 @@ productsRouter.post("/products", (req, res) => {
});
type UpdateBody = Partial<{
name: string;
type: string;
kind: "bulk" | "discrete";
strainId: string | null;
strainName: string;
brandId: string | null;
}>;
productsRouter.patch("/products/:id", (req, res) => {
@@ -100,24 +105,47 @@ productsRouter.patch("/products/:id", (req, res) => {
const existing = db
.prepare<
[string],
{ id: string; name: string; type: string; kind: string; strain_id: string | null }
{ id: string; type: string; kind: string; strain_id: string; brand_id: string | null }
>(
`SELECT id, name, type, kind, strain_id FROM products WHERE id = ?`,
`SELECT id, type, kind, strain_id, brand_id FROM products WHERE id = ?`,
)
.get(id);
if (!existing) return res.status(404).json({ error: "product not found" });
const nextName =
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;
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 name = ?, type = ?, kind = ?, strain_id = ? WHERE id = ?`,
).run(nextName, nextType, nextKind, nextStrainId, id);
`UPDATE products SET type = ?, kind = ?, strain_id = ?, brand_id = ? WHERE id = ?`,
).run(nextType, nextKind, nextStrainId, nextBrandId, id);
res.json({ ok: true });
});
+13 -10
View File
@@ -30,30 +30,33 @@ CREATE TABLE IF NOT EXISTS strains (
created_at TEXT NOT NULL
);
-- Products: catalog entries. One row per (sku, strain) combination —
-- a "kind of thing you can scan". Type/kind describe the physical form
-- (e.g. Flower/bulk, Pre-roll/discrete) since the same strain can ship in
-- different forms with different SKUs.
-- Products: catalog entries. One row per SKU. The strain (required)
-- supplies the display name — there is no separate product name field.
-- Brand sits here too: a SKU is brand-specific (UPC barcodes belong to
-- a single brand), so all instances of a product share the brand.
-- Type/kind describe the physical form (e.g. Flower/bulk, Pre-roll/discrete);
-- the same strain can ship in different forms with different SKUs.
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
strain_id TEXT REFERENCES strains(id),
name TEXT NOT NULL,
strain_id TEXT NOT NULL REFERENCES strains(id),
brand_id TEXT REFERENCES brands(id),
type TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_products_strain ON products(strain_id);
CREATE INDEX IF NOT EXISTS idx_products_brand ON products(brand_id);
-- Inventory items: physical things you bought. One row per acquired
-- jar/pack/cart, with its own short-UUID asset id (label-printed).
-- Per-batch values (price, THC, weight or count, brand, shop, bin)
-- live here, since they vary between purchases of the same product.
-- jar/pack/cart, with a user-supplied 6-digit asset id (the label number
-- on a roll of pre-printed asset tags). Per-batch values (price, THC,
-- weight/count, shop, bin) live here, since they vary between purchases
-- of the same product. Brand is on the product, not here.
CREATE TABLE IF NOT EXISTS inventory_items (
id TEXT PRIMARY KEY,
asset_id TEXT NOT NULL UNIQUE,
product_id TEXT NOT NULL REFERENCES products(id),
brand_id TEXT REFERENCES brands(id),
shop_id TEXT REFERENCES shops(id),
bin_id TEXT REFERENCES bins(id),
price REAL NOT NULL,