Edit existing products

Adds PATCH /products/:id and an EditProductFlow modal opened from the
product drawer. Editable fields cover name, brand, shop, bin, asset tag,
price, purchase date, size (weight or count + unit weight), and the
cannabinoid profile. SKU, type, kind, and status-derived dates stay
locked because changing them would invalidate audit history math; type
changes are surfaced as "mark gone, add new" in the modal.

The strain row is re-resolved on name or brand change so analytics stay
aligned, and the last-audit mirror (last_audit_weight / count_last_audit)
only syncs with the original size when there are no audits yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 21:42:33 -04:00
parent 8ef8859c7d
commit 592bb28740
5 changed files with 586 additions and 0 deletions
+193
View File
@@ -111,6 +111,199 @@ productsRouter.post("/products", (req, res) => {
res.json({ id });
});
type UpdateBody = Partial<{
name: string;
brandId: string | null;
shopId: string | null;
binId: string | null;
assetTag: string | null;
weight: number;
countOriginal: number;
unitWeight: number;
price: number;
thc: number;
cbd: number;
totalCannabinoids: number;
purchaseDate: string;
}>;
productsRouter.patch("/products/:id", (req, res) => {
const { id } = req.params;
const body = req.body as UpdateBody;
type Row = {
id: string;
name: string;
brand_id: string | null;
shop_id: string | null;
bin_id: string | null;
asset_tag: string | null;
type: string;
kind: "bulk" | "discrete";
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;
strain_id: string | null;
};
const existing = db
.prepare<[string], Row>(
`SELECT id, name, brand_id, shop_id, bin_id, asset_tag, type, kind,
weight, last_audit_weight, count_original, count_last_audit, unit_weight,
price, thc, cbd, total_cannabinoids, purchase_date, strain_id
FROM products WHERE id = ?`,
)
.get(id);
if (!existing) return res.status(404).json({ error: "product not found" });
const auditCount = db
.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM audits WHERE product_id = ?")
.get(id)!.n;
const trimOrUndef = (v: unknown) =>
typeof v === "string" ? v.trim() : v;
const nextName =
body.name !== undefined && (body.name as string).trim()
? (body.name as string).trim()
: existing.name;
const nextBrandId =
body.brandId === undefined ? existing.brand_id : body.brandId || null;
const nextShopId =
body.shopId === undefined ? existing.shop_id : body.shopId || null;
const nextBinId =
body.binId === undefined ? existing.bin_id : body.binId || null;
const nextAssetTag =
body.assetTag === undefined
? existing.asset_tag
: (trimOrUndef(body.assetTag) as string | null) || null;
const nextPrice =
Number.isFinite(body.price) && (body.price as number) >= 0
? (body.price as number)
: existing.price;
const nextPurchaseDate =
typeof body.purchaseDate === "string" && body.purchaseDate.trim()
? body.purchaseDate.trim()
: existing.purchase_date;
const nextThc =
Number.isFinite(body.thc) ? (body.thc as number) : existing.thc;
const nextCbd =
Number.isFinite(body.cbd) ? (body.cbd as number) : existing.cbd;
const nextTotalCanna = Number.isFinite(body.totalCannabinoids)
? (body.totalCannabinoids as number)
: existing.total_cannabinoids;
const isDiscrete = existing.kind === "discrete";
const nextWeight =
!isDiscrete && Number.isFinite(body.weight) && (body.weight as number) >= 0
? (body.weight as number)
: existing.weight;
const nextCountOriginal =
isDiscrete && Number.isFinite(body.countOriginal) && (body.countOriginal as number) >= 0
? Math.floor(body.countOriginal as number)
: existing.count_original;
const nextUnitWeight =
isDiscrete && Number.isFinite(body.unitWeight) && (body.unitWeight as number) >= 0
? (body.unitWeight as number)
: existing.unit_weight;
// If no audits exist yet, keep the "last audit" mirror in lock-step with
// the original size — otherwise the next audit's prev_value would be stale.
const nextLastAuditWeight =
!isDiscrete && auditCount === 0 ? nextWeight : existing.last_audit_weight;
const nextCountLastAudit =
isDiscrete && auditCount === 0 ? nextCountOriginal : existing.count_last_audit;
const tx = db.transaction(() => {
let nextStrainId = existing.strain_id;
const nameChanged = nextName !== existing.name;
const brandChanged = nextBrandId !== existing.brand_id;
if (nameChanged || brandChanged) {
const todayIso = new Date().toISOString().slice(0, 10);
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(nextName, nextBrandId, nextBrandId, existing.type);
if (found) {
nextStrainId = found.id;
} else {
nextStrainId = nextId("str", "strains");
db.prepare(`
INSERT INTO strains (
id, name, brand_id, type,
default_thc, default_cbd, default_total_cannabinoids, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
nextStrainId,
nextName,
nextBrandId,
existing.type,
nextThc,
nextCbd,
nextTotalCanna,
todayIso,
);
}
}
db.prepare(`
UPDATE products SET
name = @name,
brand_id = @brandId,
shop_id = @shopId,
bin_id = @binId,
asset_tag = @assetTag,
weight = @weight,
last_audit_weight = @lastAuditWeight,
count_original = @countOriginal,
count_last_audit = @countLastAudit,
unit_weight = @unitWeight,
price = @price,
thc = @thc,
cbd = @cbd,
total_cannabinoids = @totalCannabinoids,
purchase_date = @purchaseDate,
strain_id = @strainId
WHERE id = @id
`).run({
id,
name: nextName,
brandId: nextBrandId,
shopId: nextShopId,
binId: nextBinId,
assetTag: nextAssetTag,
weight: nextWeight,
lastAuditWeight: nextLastAuditWeight,
countOriginal: nextCountOriginal,
countLastAudit: nextCountLastAudit,
unitWeight: nextUnitWeight,
price: nextPrice,
thc: nextThc,
cbd: nextCbd,
totalCannabinoids: nextTotalCanna,
purchaseDate: nextPurchaseDate,
strainId: nextStrainId,
});
});
tx();
res.json({ ok: true });
});
productsRouter.post("/products/:id/finish", (req, res) => {
const { id } = req.params;
const { date, rating, notes } = req.body as { date: string; rating?: number; notes?: string };