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
+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 });
});