Add product model catalog for v3.1.0

This commit is contained in:
mfwadejr
2026-08-30 08:49:37 -04:00
committed by GitHub
parent 6b941865ae
commit 415589309a
4 changed files with 52 additions and 8 deletions
+25 -5
View File
@@ -10,11 +10,13 @@ const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
let db=new DatabaseSync(databasePath);
const legacyProductSchema=()=>String(db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='products'").get()?.sql||"").includes("CHECK(model IN");
if(legacyProductSchema())await backup(db,join(backupDir,`pre-model-catalog-${new Date().toISOString().replace(/[:.]/g,"-")}.db`));
function initializeDatabase(){
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 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,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,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 '',payment_method TEXT NOT NULL DEFAULT '',payment_reference TEXT NOT NULL DEFAULT '',sale_notes TEXT NOT NULL DEFAULT '');
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 TABLE IF NOT EXISTS customer_notes (id TEXT PRIMARY KEY,customer_id TEXT NOT NULL,category TEXT NOT NULL DEFAULT 'General',note TEXT NOT NULL,created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,FOREIGN KEY(customer_id) REFERENCES customers(id) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY,username TEXT NOT NULL COLLATE NOCASE UNIQUE,password_hash TEXT NOT NULL,role TEXT NOT NULL CHECK(role IN ('admin','readonly')),enabled INTEGER NOT NULL DEFAULT 1,must_change_password INTEGER NOT NULL DEFAULT 0,created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,last_login_at TEXT);
@@ -22,7 +24,17 @@ function initializeDatabase(){
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!=''; CREATE INDEX IF NOT EXISTS idx_customer_notes_customer_id ON customer_notes(customer_id); CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_log(created_at DESC);`);
const cols=new Set(db.prepare("PRAGMA table_info(products)").all().map(c=>c.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 ''"],["payment_method","TEXT NOT NULL DEFAULT ''"],["payment_reference","TEXT NOT NULL DEFAULT ''"],["sale_notes","TEXT NOT NULL DEFAULT ''"]])if(!cols.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)");
if(legacyProductSchema()){
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
try{db.exec(`CREATE TABLE products_model_migration (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,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,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 '',payment_method TEXT NOT NULL DEFAULT '',payment_reference TEXT NOT NULL DEFAULT '',sale_notes TEXT NOT NULL DEFAULT '');
INSERT INTO products_model_migration SELECT id,uid,sn,mac,manufacturer,model,condition,received_at,cost,notes,status,sold_at,customer_name,phone,sale_price,customer_id,ship_address1,ship_address2,ship_city,ship_state,ship_zip,shipping_notes,payment_method,payment_reference,sale_notes FROM products;
DROP TABLE products; ALTER TABLE products_model_migration RENAME TO products; COMMIT;`)}catch(error){db.exec("ROLLBACK");throw error}finally{db.exec("PRAGMA foreign_keys=ON")}
}
db.exec(`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!=''; CREATE INDEX IF NOT EXISTS idx_products_customer_id ON products(customer_id);
CREATE TABLE IF NOT EXISTS product_models (id TEXT PRIMARY KEY,name TEXT NOT NULL COLLATE NOCASE UNIQUE,active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);`);
const addModel=db.prepare("INSERT OR IGNORE INTO product_models (id,name) VALUES (?,?)");
for(const row of db.prepare("SELECT DISTINCT model FROM products WHERE trim(model)!=''").all())addModel.run(crypto.randomUUID(),row.model);
for(const name of ["V3 Plus","V5 Pro","V6 Plus","V6 Pro"])addModel.run(crypto.randomUUID(),name);
}
const hashPassword=password=>{const salt=randomBytes(16).toString("hex");return `scrypt$${salt}$${scryptSync(password,salt,64).toString("hex")}`};
function verifyPassword(password,stored){try{const[kind,salt,hash]=stored.split("$");if(kind!=="scrypt")return false;const actual=scryptSync(password,salt,64),expected=Buffer.from(hash,"hex");return actual.length===expected.length&&timingSafeEqual(actual,expected)}catch{return false}}
@@ -37,7 +49,7 @@ db.exec("PRAGMA optimize");
if(process.argv[2]==="reset-admin"){const username=cleanUsername(process.argv[3]||"admin"),password=validPassword(process.argv[4]||process.env.RESET_ADMIN_PASSWORD||""),existing=db.prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE").get(username);if(existing)db.prepare("UPDATE users SET password_hash=?,role='admin',enabled=1,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(password),existing.id);else db.prepare("INSERT INTO users (id,username,password_hash,role,enabled,must_change_password) VALUES (?,?,?,'admin',1,1)").run(crypto.randomUUID(),username,hashPassword(password));db.prepare("INSERT INTO audit_log (username,action,target,details) VALUES ('system','emergency_admin_reset',?,'Console reset; password change required')").run(username);console.log(`Administrator ${username} reset. Password change required at next login.`);db.close();process.exit(0)}
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,payment_method AS paymentMethod,payment_reference AS paymentReference,sale_notes AS saleNotes";
const allowedModels=new Set(["V3 Plus","V6 Plus","V6 Pro","V5 Pro"]),allowedConditions=new Set(["New","Used","Refurbished"]);
const allowedConditions=new Set(["New","Used","Refurbished"]);
const getProduct=()=>db.prepare(`SELECT ${columns} FROM products WHERE id=?`),listProducts=()=>db.prepare(`SELECT ${columns} FROM products ORDER BY CASE WHEN status='available' THEN received_at ELSE sold_at END DESC,rowid DESC`);
function json(res,status,value,headers={}){const b=JSON.stringify(value);res.writeHead(status,{"content-type":"application/json","content-length":Buffer.byteLength(b),...headers});res.end(b)}
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):{}}
@@ -57,7 +69,10 @@ function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error
function validateBackup(path){const candidate=new DatabaseSync(path,{readOnly:true});try{const tables=new Set(candidate.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(x=>x.name));if(!tables.has("products")||!tables.has("customers"))throw new Error("This is not a valid vBoxStock database.");const integrity=candidate.prepare("PRAGMA integrity_check").get();if(Object.values(integrity)[0]!=="ok")throw new Error("The backup failed its integrity check.")}finally{candidate.close()}}
async function restoreFrom(path,res,req,user){validateBackup(path);const target=path.split("/").pop();await createBackup("pre-restore");audit(req,user,"database_restore",target);db.exec("PRAGMA wal_checkpoint(TRUNCATE)");db.close();await copyFile(path,databasePath);await unlink(`${databasePath}-wal`).catch(()=>{});await unlink(`${databasePath}-shm`).catch(()=>{});await writeFile(restoreMarker,JSON.stringify({username:user.username,target,details:"Database restored; all sessions invalidated",ipAddress:clientIp(req)}));sessions.clear();json(res,200,{ok:true,restarting:true});setTimeout(()=>process.exit(0),250)}
function confirmPassword(user,password){const record=db.prepare("SELECT password_hash FROM users WHERE id=?").get(user.id);if(!record||!verifyPassword(String(password||""),record.password_hash))throw Object.assign(new Error("Current password is incorrect."),{status:403})}
function productInput(v){const model=String(v.model||""),condition=String(v.condition||"");if(!allowedModels.has(model)||!allowedConditions.has(condition)||!v.receivedAt)throw new Error("Model, condition, and received date are required.");return{uid:String(v.uid||"").trim(),sn:String(v.sn||"").trim(),mac:String(v.mac||"").trim(),model,condition,receivedAt:String(v.receivedAt),cost:Number(v.cost)||0,notes:String(v.notes||"").trim()}}
function cleanModelName(value){const name=String(value||"").trim().replace(/\s+/g," ");if(!name||name.length>60||/[\u0000-\u001f\u007f]/.test(name))throw new Error("Model name must contain 160 characters.");return name}
function productInput(v){const requested=String(v.model||"").trim(),modelRecord=db.prepare("SELECT name FROM product_models WHERE name=? COLLATE NOCASE AND active=1").get(requested),condition=String(v.condition||"");if(!modelRecord||!allowedConditions.has(condition)||!v.receivedAt)throw new Error("An active model, condition, and received date are required.");return{uid:String(v.uid||"").trim(),sn:String(v.sn||"").trim(),mac:String(v.mac||"").trim(),model:modelRecord.name,condition,receivedAt:String(v.receivedAt),cost:Number(v.cost)||0,notes:String(v.notes||"").trim()}}
function modelUsage(id){return db.prepare("SELECT m.id,m.name,m.active,COUNT(p.id) totalCount,COALESCE(SUM(CASE WHEN p.status='available' THEN 1 ELSE 0 END),0) availableCount,COALESCE(SUM(CASE WHEN p.status='sold' THEN 1 ELSE 0 END),0) soldCount FROM product_models m LEFT JOIN products p ON p.model=m.name COLLATE NOCASE WHERE m.id=? GROUP BY m.id").get(id)}
function allModels(){return db.prepare("SELECT m.id,m.name,m.active,m.created_at AS createdAt,m.updated_at AS updatedAt,COUNT(p.id) totalCount,COALESCE(SUM(CASE WHEN p.status='available' THEN 1 ELSE 0 END),0) availableCount,COALESCE(SUM(CASE WHEN p.status='sold' THEN 1 ELSE 0 END),0) soldCount FROM product_models m LEFT JOIN products p ON p.model=m.name COLLATE NOCASE GROUP BY m.id ORDER BY lower(m.name)").all().map(x=>({...x,active:Boolean(x.active)}))}
async function authApi(req,res,url,session){
if(url.pathname==="/api/auth/login"&&req.method==="POST"){const v=await body(req),username=String(v.username||"").trim(),key=`${clientIp(req)}|${username.toLowerCase()}`,failure=loginFailures.get(key);if(failure&&failure.count>=5&&failure.until>Date.now())throw Object.assign(new Error("Too many failed attempts. Try again in 15 minutes."),{status:429});const user=db.prepare("SELECT id,username,password_hash,role,enabled,must_change_password AS mustChangePassword FROM users WHERE username=? COLLATE NOCASE").get(username);if(!user?.enabled||!verifyPassword(String(v.password||""),user.password_hash)){loginFailures.set(key,{count:(failure?.count||0)+1,until:Date.now()+15*60*1000});audit(req,user,"login_failed",username);throw Object.assign(new Error("Invalid username or password."),{status:401})}loginFailures.delete(key);const token=randomBytes(32).toString("base64url");sessions.set(tokenKey(token),{userId:user.id,lastSeen:Date.now()});db.prepare("UPDATE users SET last_login_at=CURRENT_TIMESTAMP WHERE id=?").run(user.id);audit(req,user,"login_success");return json(res,200,{username:user.username,role:user.role,mustChangePassword:Boolean(user.mustChangePassword)},{"set-cookie":sessionCookie(req,token)})}
@@ -69,6 +84,10 @@ async function authApi(req,res,url,session){
async function adminApi(req,res,url,session){
const user=session.user;
if(url.pathname==="/api/admin/models"&&req.method==="GET")return json(res,200,allModels());
if(url.pathname==="/api/admin/models"&&req.method==="POST"){const v=await body(req),name=cleanModelName(v.name),id=crypto.randomUUID();db.prepare("INSERT INTO product_models (id,name) VALUES (?,?)").run(id,name);audit(req,user,"model_created",name);return json(res,201,{id,name,active:true,totalCount:0,availableCount:0,soldCount:0})}
const modelMatch=url.pathname.match(/^\/api\/admin\/models\/([^/]+)$/);
if(modelMatch){const id=decodeURIComponent(modelMatch[1]),target=modelUsage(id);if(!target)return json(res,404,{error:"Model not found"});if(req.method==="PATCH"){const v=await body(req);if(Object.hasOwn(v,"name")){if(target.totalCount)throw new Error("A model used by inventory or sales cannot be renamed. Archive it and create a new model instead.");const name=cleanModelName(v.name);db.prepare("UPDATE product_models SET name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,id);audit(req,user,"model_renamed",target.name,`Renamed to ${name}`);return json(res,200,{ok:true})}if(Object.hasOwn(v,"active")){const active=Boolean(v.active);db.prepare("UPDATE product_models SET active=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(active?1:0,id);audit(req,user,active?"model_reactivated":"model_archived",target.name,`${target.availableCount} available; ${target.soldCount} sold`);return json(res,200,{ok:true})}throw new Error("Model name or status is required.")}if(req.method==="DELETE"){if(target.totalCount)throw new Error("A model used by inventory or sales cannot be deleted. Archive it instead.");db.prepare("DELETE FROM product_models WHERE id=?").run(id);audit(req,user,"model_deleted",target.name);return json(res,204,null)}}
if(url.pathname==="/api/admin/users"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,username,role,enabled,must_change_password AS mustChangePassword,created_at AS createdAt,last_login_at AS lastLoginAt FROM users ORDER BY lower(username)").all().map(x=>({...x,enabled:Boolean(x.enabled),mustChangePassword:Boolean(x.mustChangePassword)})));
if(url.pathname==="/api/admin/users"&&req.method==="POST"){const v=await body(req),username=cleanUsername(v.username),password=validPassword(v.password),role=String(v.role);if(!new Set(["admin","readonly"]).has(role))throw new Error("Invalid role.");const id=crypto.randomUUID();db.prepare("INSERT INTO users (id,username,password_hash,role,must_change_password) VALUES (?,?,?,?,1)").run(id,username,hashPassword(password),role);audit(req,user,"user_created",username,role);return json(res,201,{id,username,role,enabled:true,mustChangePassword:true})}
const userMatch=url.pathname.match(/^\/api\/admin\/users\/([^/]+)$/);
@@ -90,6 +109,7 @@ async function api(req,res,url){
if(url.pathname.startsWith("/api/auth/")){const handled=await authApi(req,res,url,session);if(handled!==false)return handled}
if(url.pathname.startsWith("/api/admin/")){const handled=await adminApi(req,res,url,session);if(handled!==false)return handled}
const user=session.user;
if(url.pathname==="/api/models"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,name FROM product_models WHERE active=1 ORDER BY lower(name)").all());
if(url.pathname==="/api/products"&&req.method==="GET")return json(res,200,listProducts().all());
if(url.pathname==="/api/products"&&req.method==="POST"){const p=productInput(await body(req)),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);audit(req,user,"product_received",id,p.model);return json(res,201,getProduct().get(id))}
const customerMatch=url.pathname.match(/^\/api\/customers\/([^/]+)$/);
@@ -104,4 +124,4 @@ async function api(req,res,url){
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",".png":"image/png",".webmanifest":"application/manifest+json; charset=utf-8"};
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),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","cache-control":"no-store"});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,error.status||(duplicate?409:400),{error:duplicate?"That username, UID, SN, or MAC is already recorded.":error.message,code:error.code})}}).listen(port,"0.0.0.0",()=>console.log(`vBoxStock listening on port ${port}`));
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),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","cache-control":"no-store"});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,error.status||(duplicate?409:400),{error:duplicate?"That value is already recorded. Model names and product identifiers must be unique.":error.message,code:error.code})}}).listen(port,"0.0.0.0",()=>console.log(`vBoxStock listening on port ${port}`));