From 5f954dfa128557091a922a6a080d6b2cc2f8acce Mon Sep 17 00:00:00 2001 From: mfwadejr <125498560+mfwadejr@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:40:38 -0400 Subject: [PATCH] Add Stockroom container application --- Dockerfile | 13 +++++ docker-compose.yml | 11 ++++ package.json | 8 +++ server.mjs | 136 +++++++++++++++++++++++++++++++++++++++++++ stockroom-unraid.xml | 10 ++++ 5 files changed, 178 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 server.mjs create mode 100644 stockroom-unraid.xml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1001c7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine +LABEL org.opencontainers.image.source="https://github.com/mfwadejr/vseebox-stockroom" +LABEL org.opencontainers.image.description="Self-contained vSeeBox inventory and sales tracker" +WORKDIR /app +COPY package.json server.mjs ./ +COPY public ./public +RUN mkdir -p /data && chown -R node:node /app /data +ENV PORT=3000 DATA_DIR=/data NODE_ENV=production +USER node +EXPOSE 3000 +VOLUME ["/data"] +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1 +CMD ["node", "server.mjs"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0851d20 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + stockroom: + image: ghcr.io/mfwadejr/vseebox-stockroom:latest + container_name: vseebox-stockroom + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - ./data:/data + environment: + - TZ=America/New_York diff --git a/package.json b/package.json new file mode 100644 index 0000000..f5889a7 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "vseebox-stockroom", + "version": "1.2.0", + "private": true, + "type": "module", + "engines": { "node": ">=22.13.0" }, + "scripts": { "start": "node server.mjs", "test": "node --test test/*.test.mjs" } +} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..ef2d62b --- /dev/null +++ b/server.mjs @@ -0,0 +1,136 @@ +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { mkdirSync } from "node:fs"; +import { extname, join, normalize } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +const port = Number(process.env.PORT || 3000); +const dataDir = process.env.DATA_DIR || "/data"; +const publicDir = join(import.meta.dirname, "public"); +mkdirSync(dataDir, { recursive: true }); +const db = new DatabaseSync(join(dataDir, "stockroom.db")); +db.exec(` + PRAGMA journal_mode=WAL; + PRAGMA foreign_keys=ON; + CREATE TABLE IF NOT EXISTS products ( + id TEXT PRIMARY KEY, uid TEXT NOT NULL DEFAULT '', sn TEXT NOT NULL DEFAULT '', mac TEXT NOT NULL DEFAULT '', + manufacturer TEXT NOT NULL DEFAULT 'vSeeBox' CHECK(manufacturer='vSeeBox'), + model TEXT NOT NULL CHECK(model IN ('V3 Plus','V6 Plus','V6 Pro','V5 Pro')), + condition TEXT NOT NULL CHECK(condition IN ('New','Used','Refurbished')), + received_at TEXT NOT NULL, cost REAL NOT NULL DEFAULT 0, notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'available' CHECK(status IN ('available','sold')), + sold_at TEXT, customer_name TEXT, phone TEXT, sale_price REAL + ); + CREATE TABLE IF NOT EXISTS customers ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, phone TEXT NOT NULL DEFAULT '', + address1 TEXT NOT NULL DEFAULT '', address2 TEXT NOT NULL DEFAULT '', city TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', zip TEXT NOT NULL DEFAULT '', shipping_notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_products_status ON products(status); + CREATE UNIQUE INDEX IF NOT EXISTS idx_products_uid ON products(uid) WHERE uid != ''; + CREATE UNIQUE INDEX IF NOT EXISTS idx_products_sn ON products(sn) WHERE sn != ''; + CREATE UNIQUE INDEX IF NOT EXISTS idx_products_mac ON products(mac) WHERE mac != ''; +`); + +const productColumnNames = new Set(db.prepare("PRAGMA table_info(products)").all().map((column) => column.name)); +for (const [name, definition] of [ + ["customer_id", "TEXT"], ["ship_address1", "TEXT NOT NULL DEFAULT ''"], ["ship_address2", "TEXT NOT NULL DEFAULT ''"], + ["ship_city", "TEXT NOT NULL DEFAULT ''"], ["ship_state", "TEXT NOT NULL DEFAULT ''"], ["ship_zip", "TEXT NOT NULL DEFAULT ''"], + ["shipping_notes", "TEXT NOT NULL DEFAULT ''"] +]) if (!productColumnNames.has(name)) db.exec(`ALTER TABLE products ADD COLUMN ${name} ${definition}`); +db.exec("CREATE INDEX IF NOT EXISTS idx_products_customer_id ON products(customer_id)"); + +const seed = [ + ["Logan Crabtree","273D000000021255","A0:BB:3E:02:12:55"], ["Marvin Wade","273D00000002194E","A0:BB:3E:02:19:4E"], + ["Logan Crabtree","273D00000002185F","A0:BB:3E:02:18:5F"], ["Logan Crabtree","273D00000002193A","A0:BB:3E:02:19:3A"], + ["Mark Milburn","273D0000000218BD","A0:BB:3E:02:18:BD"], ["Matt Avila","273D0000000219D5","A0:BB:3E:02:19:D5"], + ["Andy Nguyen","273D0000000219FD","A0:BB:3E:02:19:FD"], ["Andy Nguyen","273D000000021181","A0:BB:3E:02:11:81"], + ["Daniel Wade","273D00000002125A","A0:BB:3E:02:12:5A"], ["Marvin Wade","273D0000000219D4","A0:BB:3E:02:19:D4"] +]; +if (db.prepare("SELECT COUNT(*) AS count FROM products").get().count === 0) { + const insert = db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,status,sold_at,customer_name) VALUES (?,?,?,?,?,?,?,?,?,?)"); + db.exec("BEGIN"); + try { seed.forEach(([name, sn, mac]) => insert.run(crypto.randomUUID(), "", sn, mac, "V3 Plus", "New", "2024-08-20", "sold", "2024-08-20", name)); db.exec("COMMIT"); } + catch (error) { db.exec("ROLLBACK"); throw error; } +} +const findCustomerByName = db.prepare("SELECT id FROM customers WHERE lower(name)=lower(?) ORDER BY updated_at DESC LIMIT 1"); +const addCustomer = db.prepare("INSERT INTO customers (id,name,phone,address1,address2,city,state,zip,shipping_notes) VALUES (?,?,?,?,?,?,?,?,?)"); +const oldSales = db.prepare("SELECT DISTINCT customer_name AS name, phone FROM products WHERE status='sold' AND customer_name IS NOT NULL AND customer_name!='' AND customer_id IS NULL").all(); +for (const old of oldSales) { + let customer = findCustomerByName.get(old.name); + if (!customer) { const id = crypto.randomUUID(); addCustomer.run(id,old.name,old.phone||"","","","","","",""); customer = { id }; } + db.prepare("UPDATE products SET customer_id=? WHERE status='sold' AND customer_id IS NULL AND lower(customer_name)=lower(?)").run(customer.id,old.name); +} +db.exec("PRAGMA optimize"); + +const columns = `id, uid, sn, mac, manufacturer, model, condition, received_at AS receivedAt, cost, notes, + status, sold_at AS soldAt, customer_id AS customerId, customer_name AS customerName, phone, sale_price AS salePrice, + ship_address1 AS shipAddress1, ship_address2 AS shipAddress2, ship_city AS shipCity, ship_state AS shipState, + ship_zip AS shipZip, shipping_notes AS shippingNotes`; +const list = db.prepare(`SELECT ${columns} FROM products ORDER BY CASE WHEN status='available' THEN received_at ELSE sold_at END DESC, rowid DESC`); +const get = db.prepare(`SELECT ${columns} FROM products WHERE id=?`); +const allowedModels = new Set(["V3 Plus", "V6 Plus", "V6 Pro", "V5 Pro"]); +const allowedConditions = new Set(["New", "Used", "Refurbished"]); + +function json(res, status, value) { const body = JSON.stringify(value); res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) }); res.end(body); } +async function body(req) { let raw = ""; for await (const chunk of req) { raw += chunk; if (raw.length > 1_000_000) throw new Error("Request too large"); } return raw ? JSON.parse(raw) : {}; } +function productInput(value) { + const model = String(value.model || ""); const condition = String(value.condition || ""); + if (!allowedModels.has(model) || !allowedConditions.has(condition) || !value.receivedAt) throw new Error("Model, condition, and received date are required."); + return { uid:String(value.uid||"").trim(), sn:String(value.sn||"").trim(), mac:String(value.mac||"").trim(), model, condition, receivedAt:String(value.receivedAt), cost:Number(value.cost)||0, notes:String(value.notes||"").trim() }; +} + +async function api(req, res, url) { + if (url.pathname === "/api/health") return json(res, 200, { ok:true }); + if (url.pathname === "/api/products" && req.method === "GET") return json(res, 200, list.all()); + if (url.pathname === "/api/products" && req.method === "POST") { + const p = productInput(await body(req)); const id = crypto.randomUUID(); + db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,cost,notes) VALUES (?,?,?,?,?,?,?,?,?)").run(id,p.uid,p.sn,p.mac,p.model,p.condition,p.receivedAt,p.cost,p.notes); + return json(res, 201, get.get(id)); + } + const customerMatch = url.pathname.match(/^\/api\/customers\/([^/]+)$/); + if (customerMatch && req.method === "GET") { + const id = decodeURIComponent(customerMatch[1]); + const customer = db.prepare("SELECT id,name,phone,address1,address2,city,state,zip,shipping_notes AS shippingNotes FROM customers WHERE id=?").get(id); + if (!customer) return json(res, 404, { error:"Customer not found" }); + const purchases = db.prepare(`SELECT ${columns} FROM products WHERE status='sold' AND customer_id=? ORDER BY sold_at DESC, rowid DESC`).all(id); + return json(res, 200, { ...customer, purchases }); + } + const match = url.pathname.match(/^\/api\/products\/([^/]+)(?:\/(sell|restock))?$/); + if (!match) return json(res, 404, { error:"Not found" }); + const id = decodeURIComponent(match[1]); const action = match[2]; const current = get.get(id); + if (!current) return json(res, 404, { error:"Product not found" }); + if (req.method === "DELETE" && !action) { db.prepare("DELETE FROM products WHERE id=?").run(id); res.writeHead(204); return res.end(); } + if (req.method === "POST" && action === "sell") { + const v = await body(req); if (!String(v.customerName||"").trim() || !v.soldAt) throw new Error("Customer name and sale date are required."); + const name=String(v.customerName).trim(), phone=String(v.phone||"").trim(), address1=String(v.shipAddress1||"").trim(), address2=String(v.shipAddress2||"").trim(), city=String(v.shipCity||"").trim(), state=String(v.shipState||"").trim(), zip=String(v.shipZip||"").trim(), shippingNotes=String(v.shippingNotes||"").trim(); + let customer = v.customerId ? db.prepare("SELECT id FROM customers WHERE id=?").get(String(v.customerId)) : findCustomerByName.get(name); + if (!customer) { customer={id:crypto.randomUUID()}; addCustomer.run(customer.id,name,phone,address1,address2,city,state,zip,shippingNotes); } + else db.prepare("UPDATE customers SET name=?,phone=?,address1=?,address2=?,city=?,state=?,zip=?,shipping_notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,address1,address2,city,state,zip,shippingNotes,customer.id); + db.prepare("UPDATE products SET status='sold', sold_at=?, customer_id=?, customer_name=?, phone=?, sale_price=?, ship_address1=?, ship_address2=?, ship_city=?, ship_state=?, ship_zip=?, shipping_notes=? WHERE id=? AND status='available'").run(String(v.soldAt),customer.id,name,phone,Number(v.salePrice)||0,address1,address2,city,state,zip,shippingNotes,id); + return json(res, 200, get.get(id)); + } + if (req.method === "POST" && action === "restock") { + const v = await body(req); if (!allowedConditions.has(v.condition) || !v.receivedAt) throw new Error("Condition and return date are required."); + db.prepare("UPDATE products SET status='available', condition=?, received_at=?, sold_at=NULL, customer_id=NULL, customer_name=NULL, phone=NULL, sale_price=NULL, ship_address1='', ship_address2='', ship_city='', ship_state='', ship_zip='', shipping_notes='' WHERE id=? AND status='sold'").run(v.condition,String(v.receivedAt),id); + return json(res, 200, get.get(id)); + } + return json(res, 405, { error:"Method not allowed" }); +} + +const mime = { ".html":"text/html; charset=utf-8", ".css":"text/css; charset=utf-8", ".js":"text/javascript; charset=utf-8", ".svg":"image/svg+xml" }; +createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + if (url.pathname.startsWith("/api/")) return await api(req, res, url); + const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1); + const file = normalize(join(publicDir, requested)); + if (!file.startsWith(publicDir) || !(await stat(file)).isFile()) throw new Error("NOT_FOUND"); + const content = await readFile(file); res.writeHead(200, { "content-type": mime[extname(file)] || "application/octet-stream" }); res.end(content); + } catch (error) { + if (error.message === "NOT_FOUND" || error.code === "ENOENT") return json(res, 404, { error:"Not found" }); + const duplicate = String(error.message).includes("UNIQUE constraint failed"); + json(res, duplicate ? 409 : 400, { error: duplicate ? "That UID, SN, or MAC is already recorded." : error.message }); + } +}).listen(port, "0.0.0.0", () => console.log(`Stockroom listening on port ${port}`)); diff --git a/stockroom-unraid.xml b/stockroom-unraid.xml new file mode 100644 index 0000000..bd412c3 --- /dev/null +++ b/stockroom-unraid.xml @@ -0,0 +1,10 @@ + + + vSeeBox-Stockroomghcr.io/mfwadejr/vseebox-stockroom:latesthttps://github.com/mfwadejr/vseebox-stockroom/pkgs/container/vseebox-stockroom + bridgeshfalse + Self-contained vSeeBox inventory and sales tracker with an embedded SQLite database. + Tools:http://[IP]:[PORT:3000]/ + 3000 + /mnt/user/appdata/vseebox-stockroom + America/New_York +