02dc6e523f
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>
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import { readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
const DB_PATH = process.env.APOTHECARY_DB ?? join(__dirname, "..", "data.db");
|
|
|
|
export const db = new Database(DB_PATH);
|
|
db.pragma("journal_mode = WAL");
|
|
db.pragma("foreign_keys = ON");
|
|
|
|
archiveLegacyIfPresent();
|
|
|
|
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.
|
|
function archiveLegacyIfPresent(): void {
|
|
const productCols = db
|
|
.prepare(`PRAGMA table_info(products)`)
|
|
.all() as { name: string }[];
|
|
const looksLikeOldSchema =
|
|
productCols.length > 0 && productCols.some((c) => c.name === "weight");
|
|
if (!looksLikeOldSchema) return;
|
|
|
|
const legacyExists = db
|
|
.prepare(
|
|
`SELECT name FROM sqlite_master WHERE type='table' AND name='products_legacy'`,
|
|
)
|
|
.get();
|
|
if (legacyExists) return;
|
|
|
|
db.exec(`
|
|
ALTER TABLE products RENAME TO products_legacy;
|
|
ALTER TABLE audits RENAME TO audits_legacy;
|
|
ALTER TABLE strains RENAME TO strains_legacy;
|
|
`);
|
|
}
|
|
|
|
const PAD: Record<string, number> = {
|
|
prd: 4,
|
|
pdt: 4,
|
|
inv: 4,
|
|
str: 4,
|
|
brd: 2,
|
|
shp: 2,
|
|
bin: 2,
|
|
};
|
|
|
|
export function nextId(prefix: string, table: string): string {
|
|
const row = db
|
|
.prepare<[string], { id: string }>(
|
|
`SELECT id FROM ${table} WHERE id LIKE ? ORDER BY id DESC LIMIT 1`,
|
|
)
|
|
.get(`${prefix}-%`);
|
|
const last = row ? Number(row.id.slice(prefix.length + 1)) : 0;
|
|
const pad = PAD[prefix] ?? 2;
|
|
const n = String(last + 1).padStart(pad, "0");
|
|
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();
|
|
}
|