User-supplied asset ids; brand on product; strain is the name
Build and push image / build (push) Successful in 48s
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:
+38
-28
@@ -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}$/;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user