Initial commit: Apothecary v0.4.0

This commit is contained in:
2026-05-03 20:19:26 -04:00
commit 027cf032be
55 changed files with 14678 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
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");
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
db.exec(schema);
// Add strain_id to products table if missing — older DBs predate the column.
const productCols = db
.prepare(`PRAGMA table_info(products)`)
.all() as { name: string }[];
if (!productCols.some((c) => c.name === "strain_id")) {
db.exec(`ALTER TABLE products ADD COLUMN strain_id TEXT REFERENCES strains(id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_products_strain ON products(strain_id);`);
}
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 = prefix === "prd" || prefix === "str" ? 4 : 2;
const n = String(last + 1).padStart(pad, "0");
return `${prefix}-${n}`;
}
export function randomSku(): string {
return "SKU-" + Math.random().toString(36).slice(2, 8).toUpperCase();
}
+21
View File
@@ -0,0 +1,21 @@
import cors from "cors";
import express from "express";
import { seedIfEmpty } from "./seed.js";
import { bootstrapRouter } from "./routes/bootstrap.js";
import { productsRouter } from "./routes/products.js";
import { catalogRouter } from "./routes/catalog.js";
seedIfEmpty();
const app = express();
app.use(cors());
app.use(express.json({ limit: "1mb" }));
app.use("/api", bootstrapRouter);
app.use("/api", productsRouter);
app.use("/api", catalogRouter);
const PORT = Number(process.env.PORT ?? 4000);
app.listen(PORT, () => {
console.log(`[apothecary] api listening on http://localhost:${PORT}`);
});
+128
View File
@@ -0,0 +1,128 @@
import { Router } from "express";
import { db } from "../db.js";
export const bootstrapRouter: Router = Router();
type ProductRow = {
id: string;
sku: string;
asset_tag: string | null;
name: string;
brand_id: string | null;
shop_id: string | null;
bin_id: string | null;
type: string;
kind: string;
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;
status: string;
consumed_date: string | null;
gone_date: string | null;
rating: number | null;
notes: string | null;
strain_id: string | null;
};
type StrainRow = {
id: string;
name: string;
brand_id: string | null;
type: string;
default_thc: number | null;
default_cbd: number | null;
default_total_cannabinoids: number | null;
notes: string | null;
};
type AuditRow = {
id: number;
product_id: string;
date: string;
mode: string;
value: number;
prev_value: number | null;
confirmed_by: string | null;
};
bootstrapRouter.get("/bootstrap", (_req, res) => {
const products = db.prepare<[], ProductRow>("SELECT * FROM products ORDER BY id").all();
const audits = db
.prepare<[], AuditRow>("SELECT * FROM audits ORDER BY product_id, date")
.all();
const shops = db.prepare("SELECT * FROM shops ORDER BY id").all();
const brands = db.prepare("SELECT * FROM brands ORDER BY id").all();
const bins = db.prepare("SELECT * FROM bins ORDER BY id").all();
const strains = db
.prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE")
.all();
const auditsByProduct = new Map<string, AuditRow[]>();
for (const a of audits) {
const arr = auditsByProduct.get(a.product_id) ?? [];
arr.push(a);
auditsByProduct.set(a.product_id, arr);
}
const productsOut = products.map((p) => ({
id: p.id,
sku: p.sku,
assetTag: p.asset_tag,
name: p.name,
brandId: p.brand_id,
shopId: p.shop_id,
binId: p.bin_id,
type: p.type,
kind: p.kind,
weight: p.weight,
lastAuditWeight: p.last_audit_weight,
countOriginal: p.count_original,
countLastAudit: p.count_last_audit,
unitWeight: p.unit_weight,
price: p.price,
thc: p.thc,
cbd: p.cbd,
totalCannabinoids: p.total_cannabinoids,
purchaseDate: p.purchase_date,
status: p.status,
consumedDate: p.consumed_date,
goneDate: p.gone_date,
rating: p.rating,
notes: p.notes,
strainId: p.strain_id,
audits: (auditsByProduct.get(p.id) ?? []).map((a) => ({
date: a.date,
mode: a.mode,
value: a.value,
prev: a.prev_value,
confirmedBy: a.confirmed_by,
})),
}));
const strainsOut = strains.map((s) => ({
id: s.id,
name: s.name,
brandId: s.brand_id,
type: s.type,
defaultThc: s.default_thc,
defaultCbd: s.default_cbd,
defaultTotalCannabinoids: s.default_total_cannabinoids,
notes: s.notes,
}));
res.json({
products: productsOut,
shops,
brands,
bins,
strains: strainsOut,
today: "2026-04-25",
});
});
+94
View File
@@ -0,0 +1,94 @@
import { Router } from "express";
import { db, nextId } from "../db.js";
export const catalogRouter: Router = Router();
catalogRouter.post("/brands", (req, res) => {
const { name } = req.body as { name: string };
if (!name?.trim()) return res.status(400).json({ error: "name required" });
const existing = db
.prepare<[string], { id: string }>("SELECT id FROM brands WHERE name = ?")
.get(name.trim());
if (existing) return res.json({ id: existing.id, name: name.trim() });
const id = nextId("brd", "brands");
db.prepare("INSERT INTO brands (id, name) VALUES (?, ?)").run(id, name.trim());
res.json({ id, name: name.trim() });
});
catalogRouter.post("/shops", (req, res) => {
const { name, location } = req.body as { name: string; location?: string };
if (!name?.trim()) return res.status(400).json({ error: "name required" });
const id = nextId("shp", "shops");
db.prepare("INSERT INTO shops (id, name, location) VALUES (?, ?, ?)").run(
id,
name.trim(),
location?.trim() ?? null,
);
res.json({ id, name: name.trim(), location: location?.trim() ?? null });
});
catalogRouter.post("/bins", (req, res) => {
const { name, location, capacity } = req.body as {
name: string;
location?: string;
capacity?: number;
};
if (!name?.trim()) return res.status(400).json({ error: "name required" });
const id = nextId("bin", "bins");
const cap = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : 10;
db.prepare("INSERT INTO bins (id, name, location, capacity) VALUES (?, ?, ?, ?)").run(
id,
name.trim(),
location?.trim() ?? null,
cap,
);
res.json({ id, name: name.trim(), location: location?.trim() ?? null, capacity: cap });
});
catalogRouter.patch("/bins/:id", (req, res) => {
const { id } = req.params;
const { name, location, capacity } = req.body as {
name?: string;
location?: string | null;
capacity?: number;
};
const existing = db
.prepare<[string], { id: string; name: string; location: string | null; capacity: number }>(
"SELECT id, name, location, capacity FROM bins WHERE id = ?",
)
.get(id);
if (!existing) return res.status(404).json({ error: "bin not found" });
const nextName = name?.trim() ? name.trim() : existing.name;
const nextLocation =
location === undefined ? existing.location : location?.toString().trim() || null;
const nextCapacity =
Number.isFinite(capacity) && (capacity as number) > 0
? Math.floor(capacity as number)
: existing.capacity;
db.prepare("UPDATE bins SET name = ?, location = ?, capacity = ? WHERE id = ?").run(
nextName,
nextLocation,
nextCapacity,
id,
);
res.json({ id, name: nextName, location: nextLocation, capacity: nextCapacity });
});
// Deleting a bin unassigns any products that reference it (bin_id → NULL),
// so users never lose products when reorganizing storage.
catalogRouter.delete("/bins/:id", (req, res) => {
const { id } = req.params;
const tx = db.transaction(() => {
db.prepare("UPDATE products SET bin_id = NULL WHERE bin_id = ?").run(id);
const result = db.prepare("DELETE FROM bins WHERE id = ?").run(id);
if (result.changes === 0) throw new Error("not found");
});
try {
tx();
res.json({ ok: true });
} catch {
res.status(404).json({ error: "bin not found" });
}
});
+182
View File
@@ -0,0 +1,182 @@
import { Router } from "express";
import { db, nextId, randomSku } from "../db.js";
export const productsRouter: Router = Router();
type CreateBody = {
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;
};
productsRouter.post("/products", (req, res) => {
const body = req.body as CreateBody;
if (!body.name) return res.status(400).json({ error: "name required" });
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 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);
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,
);
}
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,
});
});
tx();
res.json({ id });
});
productsRouter.post("/products/:id/finish", (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'",
)
.run(date, rating ?? null, notes ?? null, id);
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
res.json({ ok: true });
});
productsRouter.post("/products/:id/gone", (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" });
}
});
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 = ?",
)
.get(id);
if (!product) return res.status(404).json({ error: "not found" });
const prev =
product.kind === "discrete"
? product.count_last_audit ?? product.count_original
: product.last_audit_weight ?? product.weight;
const tx = db.transaction(() => {
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);
}
});
tx();
res.json({ ok: true });
});
+72
View File
@@ -0,0 +1,72 @@
CREATE TABLE IF NOT EXISTS shops (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT
);
CREATE TABLE IF NOT EXISTS brands (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS bins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
capacity INTEGER NOT NULL DEFAULT 10
);
CREATE TABLE IF NOT EXISTS strains (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
brand_id TEXT REFERENCES brands(id),
type TEXT NOT NULL,
default_thc REAL,
default_cbd REAL,
default_total_cannabinoids REAL,
notes TEXT,
created_at TEXT NOT NULL,
UNIQUE(name, brand_id, type)
);
CREATE INDEX IF NOT EXISTS idx_strains_brand_type ON strains(brand_id, type);
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
sku TEXT NOT NULL,
asset_tag TEXT,
name TEXT NOT NULL,
brand_id TEXT REFERENCES brands(id),
shop_id TEXT REFERENCES shops(id),
bin_id TEXT REFERENCES bins(id),
type TEXT NOT NULL,
kind TEXT NOT NULL,
weight REAL DEFAULT 0,
last_audit_weight REAL,
count_original INTEGER DEFAULT 0,
count_last_audit INTEGER,
unit_weight REAL DEFAULT 0,
price REAL NOT NULL,
thc REAL DEFAULT 0,
cbd REAL DEFAULT 0,
total_cannabinoids REAL DEFAULT 0,
purchase_date TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
consumed_date TEXT,
gone_date TEXT,
rating INTEGER,
notes TEXT
);
CREATE TABLE IF NOT EXISTS audits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
date TEXT NOT NULL,
mode TEXT NOT NULL,
value REAL NOT NULL,
prev_value REAL,
confirmed_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_products_status ON products(status);
CREATE INDEX IF NOT EXISTS idx_audits_product ON audits(product_id, date);
+5
View File
@@ -0,0 +1,5 @@
// No seed data — the app starts completely empty. Users build their own
// catalog of bins, brands, shops, and products.
export function seedIfEmpty(): void {
// Nothing to do.
}