Release v3.3.0 operational polish

This commit is contained in:
mfwadejr
2026-08-30 16:15:59 -04:00
committed by GitHub
parent bc0f55caf5
commit 4cd2861e01
5 changed files with 64 additions and 9 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## 3.3.0
- Added returning-customer autocomplete and server-side duplicate safeguards using customer IDs, normalized names, and phone numbers.
- Expanded sale correction to include customer, phone, sale date, price, payment method, payment reference, fulfillment, tracking, warranty, and notes.
- Added model, payment, fulfillment, warranty-status, and date-range filters.
- Added CSV exports for inventory, sales, customers, warranties, and the administrator audit log.
- Added configurable daily SQLite backups with local-time scheduling and automatic retention pruning.
- Added an Admin diagnostics panel with application and schema versions, Node.js version, database size, data-directory writability, storage capacity, time zone, and backup health.
- Prevented duplicate sale submissions while a save is in progress.
- Corrected the delivery-exception status value shared by the UI and API.
- Expanded API tests for full sale correction, customer reuse, exports, diagnostics, and backup settings.
## 3.2.0 ## 3.2.0
- Added sale fulfillment methods for Shipped, Dropped Off, Installed At, and Meet with context-sensitive fields and validation. - Added sale fulfillment methods for Shipped, Dropped Off, Installed At, and Meet with context-sensitive fields and validation.
+25 -1
View File
@@ -33,10 +33,14 @@ General inventory tools can be larger and more complicated than a small reseller
- Record Cash, Venmo, or PayPal payments with an optional reference. - Record Cash, Venmo, or PayPal payments with an optional reference.
- Attach transaction notes to a sale and time-stamped support notes to a customer. - Attach transaction notes to a sale and time-stamped support notes to a customer.
- Browse inventory, sales, and customers in searchable 10-record pages. - Browse inventory, sales, and customers in searchable 10-record pages.
- Filter records by model and date, with additional payment, fulfillment, and warranty filters for sales.
- Export inventory, sales, customers, warranty configuration, and audit history to CSV.
- View sale details and complete purchase history for each customer. - View sale details and complete purchase history for each customer.
- Select returning customers during a sale and safely correct all transaction details afterward.
- Void a sale and return the device to available inventory. - Void a sale and return the device to available inventory.
- Use Admin and Read-Only accounts with server-enforced permissions. - Use Admin and Read-Only accounts with server-enforced permissions.
- Create, download, restore, and delete SQLite backups from the Admin page. - Create, download, restore, and delete SQLite backups from the Admin page.
- Schedule automatic daily backups with configurable retention and review storage/database diagnostics.
- Review an audit log of authentication, account administration, backups, and data changes. - Review an audit log of authentication, account administration, backups, and data changes.
- Keep all persistent application data in one mounted directory. - Keep all persistent application data in one mounted directory.
@@ -58,6 +62,14 @@ General inventory tools can be larger and more complicated than a small reseller
![vBoxStock warranty period administration](docs/screenshots/admin-warranties.png) ![vBoxStock warranty period administration](docs/screenshots/admin-warranties.png)
### Sales filters and CSV export
![vBoxStock sales filters and export controls](docs/screenshots/sales-filters.png)
### System diagnostics and scheduled backups
![vBoxStock system diagnostics and scheduled backup settings](docs/screenshots/admin-system-backups.png)
### User administration and database backups ### User administration and database backups
![vBoxStock user administration and database backups](docs/screenshots/admin-users-backups.jpg) ![vBoxStock user administration and database backups](docs/screenshots/admin-users-backups.jpg)
@@ -209,6 +221,10 @@ A fresh installation creates an empty inventory, sales history, and customer lis
The Admin page can create a transactionally consistent snapshot, download it to another device, restore a local snapshot, or upload and restore a downloaded copy. A pre-restore snapshot is created automatically before the active database is replaced. The Admin page can create a transactionally consistent snapshot, download it to another device, restore a local snapshot, or upload and restore a downloaded copy. A pre-restore snapshot is created automatically before the active database is replaced.
Daily automatic backups can be enabled under **Admin → System and data tools**. Choose a local-time hour from `0` through `23` and retain between 1 and 365 scheduled snapshots. Retention applies only to files named `scheduled-*.db`; manual, pre-upgrade, and pre-restore backups are never removed automatically. The application checks the schedule every 15 minutes and creates at most one scheduled backup per calendar day.
The same section reports application and database-schema versions, Node.js version, database size, `/data` writability, free disk space, configured time zone, and the latest backup. These checks are local to the container and do not transmit system information anywhere.
## Product model catalog ## Product model catalog
Administrators manage product models from **Admin → Product models**. Active models appear alphabetically in the Receive Product dropdown. Archiving a model removes it from that dropdown but does not change existing inventory, sales, customer history, or reports. Available units that use an archived model can still be sold, and an archived model can be reactivated at any time. Administrators manage product models from **Admin → Product models**. Active models appear alphabetically in the Receive Product dropdown. Archiving a model removes it from that dropdown but does not change existing inventory, sales, customer history, or reports. Available units that use an archived model can still be sold, and an archived model can be reactivated at any time.
@@ -223,7 +239,15 @@ Shipped sales support UPS, FedEx, USPS, or Other, an optional tracking number, a
Administrators manage reusable warranty periods under **Admin → Warranty periods**. The initial choices are No Warranty, 30 Days, 60 Days, 90 Days, and 1 Year. Custom durations can use days, months, or years, and one active period is the default for new sales. Once used, a period is preserved for historical accuracy and can be archived but not edited or deleted. Each sale stores the selected warranty and calculated end date as a snapshot; changing the default does not rewrite previous sales. Administrators manage reusable warranty periods under **Admin → Warranty periods**. The initial choices are No Warranty, 30 Days, 60 Days, 90 Days, and 1 Year. Custom durations can use days, months, or years, and one active period is the default for new sales. Once used, a period is preserved for historical accuracy and can be archived but not edited or deleted. Each sale stores the selected warranty and calculated end date as a snapshot; changing the default does not rewrite previous sales.
The model catalog is stored in `vboxstock.db`, so it is included automatically in every backup and restore. Upgrading from a release with the original fixed model list migrates the existing database in place and first creates a `pre-model-catalog-*.db` safety backup in `/data/backups`. ### Customer matching, filters, and exports
The sale form suggests existing customers by name and phone number. Selecting a suggestion reuses its customer ID, while the server also normalizes phone digits and names to reduce accidental duplicates. Meetup and drop-off locations remain sale-specific and do not overwrite a customer's permanent address.
Administrators can correct every sale field later, including customer, phone, date, price, payment, fulfillment, tracking, warranty, and notes. Changing the sale date recalculates the selected warranty end date. Inventory and sales can be filtered by model and date; sales also support payment, fulfillment, and warranty-status filters.
CSV downloads are available for inventory, sales, customers, and warranty configuration. Administrators can additionally export the security audit log. Exports are generated directly from the active database and do not use an external reporting service.
The model catalog is stored in `vboxstock.db`, so it is included automatically in every backup and restore. Schema-changing upgrades migrate the existing database in place and create a pre-upgrade safety backup in `/data/backups` when required.
Backups contain customer information and password hashes. Store downloaded copies securely. Restoring a database also restores the user accounts contained in that backup and signs out every active session. An older backup without user accounts starts the first-login `admin` / `admin` setup flow. Backups contain customer information and password hashes. Store downloaded copies securely. Restoring a database also restores the user accounts contained in that backup and signs out every active session. An older backup without user accounts starts the first-login `admin` / `admin` setup flow.
+1 -1
View File
@@ -36,7 +36,7 @@ x-casaos:
category: Productivity category: Productivity
architectures: architectures:
- amd64 - amd64
version: "3.2.0" version: "3.3.0"
update_at: "2026-08-30" update_at: "2026-08-30"
release_notes: release_notes:
en_US: Added administrator-managed product models with archival and historical preservation. en_US: Added administrator-managed product models with archival and historical preservation.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vboxstock", "name": "vboxstock",
"version": "3.2.0", "version": "3.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "node": ">=22.13.0" }, "engines": { "node": ">=22.13.0" },
+25 -6
View File
@@ -1,11 +1,11 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { readFile, stat, readdir, writeFile, copyFile, unlink } from "node:fs/promises"; import { readFile, stat, statfs, readdir, writeFile, copyFile, unlink } from "node:fs/promises";
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { extname, join, normalize } from "node:path"; import { extname, join, normalize } from "node:path";
import { backup, DatabaseSync } from "node:sqlite"; import { backup, DatabaseSync } from "node:sqlite";
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto"; import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
const port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data"; const APP_VERSION="3.3.0",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public"); const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public");
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map(); const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true}); mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
@@ -22,6 +22,7 @@ function initializeDatabase(){
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 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); 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);
CREATE TABLE IF NOT EXISTS audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,username TEXT NOT NULL DEFAULT 'system',action TEXT NOT NULL,target TEXT NOT NULL DEFAULT '',details TEXT NOT NULL DEFAULT '',ip_address TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,username TEXT NOT NULL DEFAULT 'system',action TEXT NOT NULL,target TEXT NOT NULL DEFAULT '',details TEXT NOT NULL DEFAULT '',ip_address TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE IF NOT EXISTS app_settings (key TEXT PRIMARY KEY,value TEXT NOT NULL,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!=''; 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);`); 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)); 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 ''"],["fulfillment_method","TEXT NOT NULL DEFAULT ''"],["fulfillment_name","TEXT NOT NULL DEFAULT ''"],["carrier","TEXT NOT NULL DEFAULT ''"],["tracking_number","TEXT NOT NULL DEFAULT ''"],["delivery_status","TEXT NOT NULL DEFAULT ''"],["delivery_status_updated_at","TEXT"],["warranty_preset_id","TEXT"],["warranty_name","TEXT NOT NULL DEFAULT ''"],["warranty_end_date","TEXT"]])if(!cols.has(name))db.exec(`ALTER TABLE products ADD COLUMN ${name} ${definition}`); 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 ''"],["fulfillment_method","TEXT NOT NULL DEFAULT ''"],["fulfillment_name","TEXT NOT NULL DEFAULT ''"],["carrier","TEXT NOT NULL DEFAULT ''"],["tracking_number","TEXT NOT NULL DEFAULT ''"],["delivery_status","TEXT NOT NULL DEFAULT ''"],["delivery_status_updated_at","TEXT"],["warranty_preset_id","TEXT"],["warranty_name","TEXT NOT NULL DEFAULT ''"],["warranty_end_date","TEXT"]])if(!cols.has(name))db.exec(`ALTER TABLE products ADD COLUMN ${name} ${definition}`);
@@ -42,6 +43,8 @@ function initializeDatabase(){
const addWarranty=db.prepare("INSERT OR IGNORE INTO warranty_presets (id,name,duration_value,duration_unit,is_default) VALUES (?,?,?,?,?)"); const addWarranty=db.prepare("INSERT OR IGNORE INTO warranty_presets (id,name,duration_value,duration_unit,is_default) VALUES (?,?,?,?,?)");
for(const [name,value,unit,isDefault] of [["No Warranty",0,"days",1],["30 Days",30,"days",0],["60 Days",60,"days",0],["90 Days",90,"days",0],["1 Year",1,"years",0]])addWarranty.run(crypto.randomUUID(),name,value,unit,isDefault); for(const [name,value,unit,isDefault] of [["No Warranty",0,"days",1],["30 Days",30,"days",0],["60 Days",60,"days",0],["90 Days",90,"days",0],["1 Year",1,"years",0]])addWarranty.run(crypto.randomUUID(),name,value,unit,isDefault);
db.prepare("UPDATE products SET fulfillment_method='Shipped' WHERE fulfillment_method='' AND status='sold' AND (ship_address1!='' OR ship_city!='' OR ship_zip!='')").run(); db.prepare("UPDATE products SET fulfillment_method='Shipped' WHERE fulfillment_method='' AND status='sold' AND (ship_address1!='' OR ship_city!='' OR ship_zip!='')").run();
const setting=db.prepare("INSERT OR IGNORE INTO app_settings (key,value) VALUES (?,?)");for(const [key,value] of [["schema_version",String(SCHEMA_VERSION)],["backup_enabled","0"],["backup_hour","2"],["backup_retention","14"],["last_scheduled_backup",""]])setting.run(key,value);
db.prepare("UPDATE app_settings SET value=? WHERE key='schema_version'").run(String(SCHEMA_VERSION));
} }
const hashPassword=password=>{const salt=randomBytes(16).toString("hex");return `scrypt$${salt}$${scryptSync(password,salt,64).toString("hex")}`}; 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}} 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}}
@@ -50,7 +53,7 @@ function cleanUsername(value){const name=String(value||"").trim();if(!/^[a-zA-Z0
initializeDatabase(); initializeDatabase();
try{const event=JSON.parse(await readFile(restoreMarker,"utf8"));db.prepare("INSERT INTO audit_log (username,action,target,details,ip_address) VALUES (?,'database_restore',?,?,?)").run(event.username||"system",event.target||"",event.details||"Restored database",event.ipAddress||"");await unlink(restoreMarker)}catch(error){if(error.code!=="ENOENT")console.error("Unable to import restore audit event:",error.message)} try{const event=JSON.parse(await readFile(restoreMarker,"utf8"));db.prepare("INSERT INTO audit_log (username,action,target,details,ip_address) VALUES (?,'database_restore',?,?,?)").run(event.username||"system",event.target||"",event.details||"Restored database",event.ipAddress||"");await unlink(restoreMarker)}catch(error){if(error.code!=="ENOENT")console.error("Unable to import restore audit event:",error.message)}
if(db.prepare("SELECT COUNT(*) count FROM users").get().count===0){db.prepare("INSERT INTO users (id,username,password_hash,role,must_change_password) VALUES (?,?,?,?,1)").run(crypto.randomUUID(),"admin",hashPassword("admin"),"admin");db.prepare("INSERT INTO audit_log (action,target,details) VALUES ('bootstrap_admin','admin','Default administrator created; password change required')").run()} if(db.prepare("SELECT COUNT(*) count FROM users").get().count===0){db.prepare("INSERT INTO users (id,username,password_hash,role,must_change_password) VALUES (?,?,?,?,1)").run(crypto.randomUUID(),"admin",hashPassword("admin"),"admin");db.prepare("INSERT INTO audit_log (action,target,details) VALUES ('bootstrap_admin','admin','Default administrator created; password change required')").run()}
const findCustomerByName=db.prepare("SELECT id FROM customers WHERE lower(name)=lower(?) ORDER BY updated_at DESC LIMIT 1"),addCustomer=db.prepare("INSERT INTO customers (id,name,phone,address1,address2,city,state,zip,shipping_notes) VALUES (?,?,?,?,?,?,?,?,?)"); const normalizePhone=value=>String(value||"").replace(/\D/g,"").slice(-10),findCustomerByName=db.prepare("SELECT id,name,phone FROM customers WHERE lower(trim(name))=lower(trim(?)) ORDER BY updated_at DESC LIMIT 1"),addCustomer=db.prepare("INSERT INTO customers (id,name,phone,address1,address2,city,state,zip,shipping_notes) VALUES (?,?,?,?,?,?,?,?,?)");
for(const old of db.prepare("SELECT DISTINCT customer_name name,phone FROM products WHERE status='sold' AND customer_name IS NOT NULL AND customer_name!='' AND customer_id IS NULL").all()){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)} for(const old of db.prepare("SELECT DISTINCT customer_name name,phone FROM products WHERE status='sold' AND customer_name IS NOT NULL AND customer_name!='' AND customer_id IS NULL").all()){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"); 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)} 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)}
@@ -72,7 +75,15 @@ function requireOrigin(req){if(["GET","HEAD","OPTIONS"].includes(req.method))ret
function authorize(req,url){if(url.pathname==="/api/health"||url.pathname==="/api/auth/login")return null;const session=currentSession(req);if(!session)throw Object.assign(new Error("Authentication required."),{status:401,code:"AUTH_REQUIRED"});if(session.user.mustChangePassword&&!new Set(["/api/auth/me","/api/auth/change-password","/api/auth/logout"]).has(url.pathname))throw Object.assign(new Error("Password change required."),{status:403,code:"PASSWORD_CHANGE_REQUIRED"});if(url.pathname.startsWith("/api/admin/")&&session.user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});if(req.method!=="GET"&&session.user.role!=="admin"&&!url.pathname.startsWith("/api/auth/"))throw Object.assign(new Error("This account is read-only."),{status:403});return session} function authorize(req,url){if(url.pathname==="/api/health"||url.pathname==="/api/auth/login")return null;const session=currentSession(req);if(!session)throw Object.assign(new Error("Authentication required."),{status:401,code:"AUTH_REQUIRED"});if(session.user.mustChangePassword&&!new Set(["/api/auth/me","/api/auth/change-password","/api/auth/logout"]).has(url.pathname))throw Object.assign(new Error("Password change required."),{status:403,code:"PASSWORD_CHANGE_REQUIRED"});if(url.pathname.startsWith("/api/admin/")&&session.user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});if(req.method!=="GET"&&session.user.role!=="admin"&&!url.pathname.startsWith("/api/auth/"))throw Object.assign(new Error("This account is read-only."),{status:403});return session}
const backupName=(prefix="vboxstock")=>`${prefix}-${new Date().toISOString().replace(/[:.]/g,"-")}.db`; const backupName=(prefix="vboxstock")=>`${prefix}-${new Date().toISOString().replace(/[:.]/g,"-")}.db`;
async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name} async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name}
const setting=key=>db.prepare("SELECT value FROM app_settings WHERE key=?").get(key)?.value||"";
function saveSetting(key,value){db.prepare("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=CURRENT_TIMESTAMP").run(key,String(value))}
async function backupFiles(){const files=await readdir(backupDir,{withFileTypes:true}),result=[];for(const file of files)if(file.isFile()&&file.name.endsWith(".db")){const info=await stat(join(backupDir,file.name));result.push({name:file.name,size:info.size,createdAt:info.mtime.toISOString()})}return result.sort((a,b)=>b.createdAt.localeCompare(a.createdAt))}
async function pruneScheduledBackups(){const keep=Math.max(1,Math.min(365,Number(setting("backup_retention"))||14)),files=(await backupFiles()).filter(x=>x.name.startsWith("scheduled-"));for(const file of files.slice(keep))await unlink(join(backupDir,file.name)).catch(()=>{})}
async function runScheduledBackup(){if(setting("backup_enabled")!=="1")return;const now=new Date(),day=now.toISOString().slice(0,10),hour=Math.max(0,Math.min(23,Number(setting("backup_hour"))||0));if(now.getHours()<hour||setting("last_scheduled_backup")===day)return;const name=await createBackup("scheduled");saveSetting("last_scheduled_backup",day);db.prepare("INSERT INTO audit_log (username,action,target,details) VALUES ('system','scheduled_backup_created',?,'Automatic scheduled backup')").run(name);await pruneScheduledBackups()}
setInterval(()=>runScheduledBackup().catch(error=>console.error("Scheduled backup failed:",error.message)),15*60*1000).unref();setTimeout(()=>runScheduledBackup().catch(error=>console.error("Scheduled backup failed:",error.message)),1000).unref();
function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)} function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)}
const csvCell=value=>`"${String(value??"").replaceAll('"','""')}"`,csv=(headers,rows)=>[headers.map(csvCell).join(","),...rows.map(row=>row.map(csvCell).join(","))].join("\r\n")+"\r\n";
function csvResponse(res,name,headers,rows){const content=csv(headers,rows);res.writeHead(200,{"content-type":"text/csv; charset=utf-8","content-disposition":`attachment; filename="${name}"`});res.end(content)}
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()}} 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)} 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 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})}
@@ -86,6 +97,8 @@ function allWarranties(){return db.prepare("SELECT w.id,w.name,w.duration_value
function warrantyEnd(start,preset){if(!preset||!preset.duration_value)return null;const [y,m,d]=String(start).split("-").map(Number),date=new Date(Date.UTC(y,m-1,d));if(preset.duration_unit==="years")date.setUTCFullYear(date.getUTCFullYear()+preset.duration_value);else if(preset.duration_unit==="months")date.setUTCMonth(date.getUTCMonth()+preset.duration_value);else date.setUTCDate(date.getUTCDate()+preset.duration_value);return date.toISOString().slice(0,10)} function warrantyEnd(start,preset){if(!preset||!preset.duration_value)return null;const [y,m,d]=String(start).split("-").map(Number),date=new Date(Date.UTC(y,m-1,d));if(preset.duration_unit==="years")date.setUTCFullYear(date.getUTCFullYear()+preset.duration_value);else if(preset.duration_unit==="months")date.setUTCMonth(date.getUTCMonth()+preset.duration_value);else date.setUTCDate(date.getUTCDate()+preset.duration_value);return date.toISOString().slice(0,10)}
const fulfillmentMethods=new Set(["Shipped","Dropped Off","Installed At","Meet"]),carriers=new Set(["UPS","FedEx","USPS","Other"]),deliveryStatuses=new Set(["Awaiting Tracking","Label Created","In Transit","Out for Delivery","Delivered","Delivery Exception","Returned","Unknown"]); const fulfillmentMethods=new Set(["Shipped","Dropped Off","Installed At","Meet"]),carriers=new Set(["UPS","FedEx","USPS","Other"]),deliveryStatuses=new Set(["Awaiting Tracking","Label Created","In Transit","Out for Delivery","Delivered","Delivery Exception","Returned","Unknown"]);
function saleDetails(v,current={}){const method=String(v.fulfillmentMethod??current.fulfillmentMethod??"").trim();if(!fulfillmentMethods.has(method))throw new Error("Delivery method is required.");const address1=String(v.shipAddress1??current.shipAddress1??"").trim(),address2=String(v.shipAddress2??current.shipAddress2??"").trim(),city=String(v.shipCity??current.shipCity??"").trim(),state=String(v.shipState??current.shipState??"").trim(),zip=String(v.shipZip??current.shipZip??"").trim(),name=String(v.fulfillmentName??current.fulfillmentName??"").trim(),notes=String(v.fulfillmentNotes??current.fulfillmentNotes??"").trim();if(new Set(["Shipped","Installed At"]).has(method)&&(!address1||!city||!state||!zip))throw new Error(`${method} requires a street address, city, state, and ZIP code.`);if(new Set(["Dropped Off","Meet"]).has(method)&&!name&&!address1&&!notes)throw new Error(`${method} requires a venue, address, or fulfillment detail.`);let carrier="",trackingNumber="",deliveryStatus="";if(method==="Shipped"){carrier=String(v.carrier??current.carrier??"").trim();if(!carriers.has(carrier))throw new Error("Carrier is required for shipped products.");trackingNumber=String(v.trackingNumber??current.trackingNumber??"").trim();deliveryStatus=String(v.deliveryStatus??(current.deliveryStatus||(trackingNumber?"Label Created":"Awaiting Tracking")));if(!deliveryStatuses.has(deliveryStatus))throw new Error("Invalid delivery status.")}return{method,name,address1,address2,city,state,zip,notes,carrier,trackingNumber,deliveryStatus}} function saleDetails(v,current={}){const method=String(v.fulfillmentMethod??current.fulfillmentMethod??"").trim();if(!fulfillmentMethods.has(method))throw new Error("Delivery method is required.");const address1=String(v.shipAddress1??current.shipAddress1??"").trim(),address2=String(v.shipAddress2??current.shipAddress2??"").trim(),city=String(v.shipCity??current.shipCity??"").trim(),state=String(v.shipState??current.shipState??"").trim(),zip=String(v.shipZip??current.shipZip??"").trim(),name=String(v.fulfillmentName??current.fulfillmentName??"").trim(),notes=String(v.fulfillmentNotes??current.fulfillmentNotes??"").trim();if(new Set(["Shipped","Installed At"]).has(method)&&(!address1||!city||!state||!zip))throw new Error(`${method} requires a street address, city, state, and ZIP code.`);if(new Set(["Dropped Off","Meet"]).has(method)&&!name&&!address1&&!notes)throw new Error(`${method} requires a venue, address, or fulfillment detail.`);let carrier="",trackingNumber="",deliveryStatus="";if(method==="Shipped"){carrier=String(v.carrier??current.carrier??"").trim();if(!carriers.has(carrier))throw new Error("Carrier is required for shipped products.");trackingNumber=String(v.trackingNumber??current.trackingNumber??"").trim();deliveryStatus=String(v.deliveryStatus??(current.deliveryStatus||(trackingNumber?"Label Created":"Awaiting Tracking")));if(!deliveryStatuses.has(deliveryStatus))throw new Error("Invalid delivery status.")}return{method,name,address1,address2,city,state,zip,notes,carrier,trackingNumber,deliveryStatus}}
function resolveCustomer(v){const id=String(v.customerId||"").trim();if(id){const customer=db.prepare("SELECT id,name,phone FROM customers WHERE id=?").get(id);if(!customer)throw new Error("Selected customer no longer exists.");return customer}const phone=normalizePhone(v.phone);if(phone){const match=db.prepare("SELECT id,name,phone FROM customers").all().find(x=>normalizePhone(x.phone)===phone);if(match)return match}return findCustomerByName.get(String(v.customerName||"").trim())}
function saveCustomer(customerId,name,phone,f){const storeAddress=new Set(["Shipped","Installed At"]).has(f.method);if(storeAddress)db.prepare("UPDATE customers SET name=?,phone=?,address1=?,address2=?,city=?,state=?,zip=?,shipping_notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,f.address1,f.address2,f.city,f.state,f.zip,f.notes,customerId);else db.prepare("UPDATE customers SET name=?,phone=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,customerId)}
async function authApi(req,res,url,session){ 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)})} 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)})}
@@ -97,6 +110,8 @@ async function authApi(req,res,url,session){
async function adminApi(req,res,url,session){ async function adminApi(req,res,url,session){
const user=session.user; const user=session.user;
if(url.pathname==="/api/admin/diagnostics"&&req.method==="GET"){const databaseInfo=await stat(databasePath),disk=await statfs(dataDir),backups=await backupFiles();let writable=true;try{const probe=join(dataDir,`.write-test-${crypto.randomUUID()}`);await writeFile(probe,"ok");await unlink(probe)}catch{writable=false}return json(res,200,{appVersion:APP_VERSION,schemaVersion:Number(setting("schema_version"))||SCHEMA_VERSION,nodeVersion:process.version,timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone,dataDirectory:dataDir,dataWritable:writable,databaseSize:databaseInfo.size,backupCount:backups.length,lastBackupAt:backups[0]?.createdAt||null,diskFree:disk.bavail*disk.bsize,diskTotal:disk.blocks*disk.bsize,backupSettings:{enabled:setting("backup_enabled")==="1",hour:Number(setting("backup_hour"))||0,retention:Number(setting("backup_retention"))||14,lastScheduledBackup:setting("last_scheduled_backup")||null}})}
if(url.pathname==="/api/admin/backup-settings"&&req.method==="PATCH"){const v=await body(req),enabled=Boolean(v.enabled),hour=Number(v.hour),retention=Number(v.retention);if(!Number.isInteger(hour)||hour<0||hour>23||!Number.isInteger(retention)||retention<1||retention>365)throw new Error("Backup hour must be 023 and retention must be 1365.");saveSetting("backup_enabled",enabled?"1":"0");saveSetting("backup_hour",hour);saveSetting("backup_retention",retention);await pruneScheduledBackups();audit(req,user,"backup_schedule_updated","",`${enabled?"enabled":"disabled"}; ${hour}:00; retain ${retention}`);return json(res,200,{ok:true})}
if(url.pathname==="/api/admin/warranties"&&req.method==="GET")return json(res,200,allWarranties()); if(url.pathname==="/api/admin/warranties"&&req.method==="GET")return json(res,200,allWarranties());
if(url.pathname==="/api/admin/warranties"&&req.method==="POST"){const v=await body(req),name=cleanWarrantyName(v.name),durationValue=Number(v.durationValue),durationUnit=String(v.durationUnit||"days");if(!Number.isInteger(durationValue)||durationValue<0||durationValue>3650||!new Set(["days","months","years"]).has(durationUnit))throw new Error("Enter a valid warranty duration.");const id=crypto.randomUUID();db.prepare("INSERT INTO warranty_presets (id,name,duration_value,duration_unit) VALUES (?,?,?,?)").run(id,name,durationValue,durationUnit);audit(req,user,"warranty_created",name,`${durationValue} ${durationUnit}`);return json(res,201,{id,name,durationValue,durationUnit,active:true,isDefault:false,usageCount:0})} if(url.pathname==="/api/admin/warranties"&&req.method==="POST"){const v=await body(req),name=cleanWarrantyName(v.name),durationValue=Number(v.durationValue),durationUnit=String(v.durationUnit||"days");if(!Number.isInteger(durationValue)||durationValue<0||durationValue>3650||!new Set(["days","months","years"]).has(durationUnit))throw new Error("Enter a valid warranty duration.");const id=crypto.randomUUID();db.prepare("INSERT INTO warranty_presets (id,name,duration_value,duration_unit) VALUES (?,?,?,?)").run(id,name,durationValue,durationUnit);audit(req,user,"warranty_created",name,`${durationValue} ${durationUnit}`);return json(res,201,{id,name,durationValue,durationUnit,active:true,isDefault:false,usageCount:0})}
const warrantyMatch=url.pathname.match(/^\/api\/admin\/warranties\/([^/]+)$/); const warrantyMatch=url.pathname.match(/^\/api\/admin\/warranties\/([^/]+)$/);
@@ -112,7 +127,7 @@ async function adminApi(req,res,url,session){
const resetMatch=url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/reset-password$/); const resetMatch=url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/reset-password$/);
if(resetMatch&&req.method==="POST"){const id=decodeURIComponent(resetMatch[1]),target=db.prepare("SELECT username FROM users WHERE id=?").get(id);if(!target)return json(res,404,{error:"User not found"});const v=await body(req);db.prepare("UPDATE users SET password_hash=?,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(validPassword(v.password)),id);invalidateUserSessions(id);audit(req,user,"password_reset",target.username);return json(res,200,{ok:true})} if(resetMatch&&req.method==="POST"){const id=decodeURIComponent(resetMatch[1]),target=db.prepare("SELECT username FROM users WHERE id=?").get(id);if(!target)return json(res,404,{error:"User not found"});const v=await body(req);db.prepare("UPDATE users SET password_hash=?,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(validPassword(v.password)),id);invalidateUserSessions(id);audit(req,user,"password_reset",target.username);return json(res,200,{ok:true})}
if(url.pathname==="/api/admin/audit"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,username,action,target,details,ip_address AS ipAddress,created_at AS createdAt FROM audit_log ORDER BY id DESC LIMIT 250").all()); if(url.pathname==="/api/admin/audit"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,username,action,target,details,ip_address AS ipAddress,created_at AS createdAt FROM audit_log ORDER BY id DESC LIMIT 250").all());
if(url.pathname==="/api/admin/backups"&&req.method==="GET"){const files=await readdir(backupDir,{withFileTypes:true}),result=[];for(const file of files)if(file.isFile()&&file.name.endsWith(".db")){const info=await stat(join(backupDir,file.name));result.push({name:file.name,size:info.size,createdAt:info.mtime.toISOString()})}return json(res,200,result.sort((a,b)=>b.createdAt.localeCompare(a.createdAt)))} if(url.pathname==="/api/admin/backups"&&req.method==="GET")return json(res,200,await backupFiles());
if(url.pathname==="/api/admin/backups"&&req.method==="POST"){const name=await createBackup("vboxstock");audit(req,user,"backup_created",name);return json(res,201,{name})} if(url.pathname==="/api/admin/backups"&&req.method==="POST"){const name=await createBackup("vboxstock");audit(req,user,"backup_created",name);return json(res,201,{name})}
if(url.pathname==="/api/admin/restore-upload"&&req.method==="POST"){confirmPassword(user,req.headers["x-confirm-password"]);const path=join(backupDir,`upload-${crypto.randomUUID()}.db`);await writeFile(path,await rawBody(req));return restoreFrom(path,res,req,user)} if(url.pathname==="/api/admin/restore-upload"&&req.method==="POST"){confirmPassword(user,req.headers["x-confirm-password"]);const path=join(backupDir,`upload-${crypto.randomUUID()}.db`);await writeFile(path,await rawBody(req));return restoreFrom(path,res,req,user)}
const backupMatch=url.pathname.match(/^\/api\/admin\/backups\/([^/]+)\/(download|restore)$/); const backupMatch=url.pathname.match(/^\/api\/admin\/backups\/([^/]+)\/(download|restore)$/);
@@ -128,6 +143,8 @@ async function api(req,res,url){
const user=session.user; 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/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/warranties"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,name,duration_value AS durationValue,duration_unit AS durationUnit,is_default AS isDefault FROM warranty_presets WHERE active=1 ORDER BY duration_value,name").all().map(x=>({...x,isDefault:Boolean(x.isDefault)}))); if(url.pathname==="/api/warranties"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,name,duration_value AS durationValue,duration_unit AS durationUnit,is_default AS isDefault FROM warranty_presets WHERE active=1 ORDER BY duration_value,name").all().map(x=>({...x,isDefault:Boolean(x.isDefault)})));
if(url.pathname==="/api/customers"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,name,phone,address1,address2,city,state,zip,shipping_notes AS shippingNotes,updated_at AS updatedAt FROM customers ORDER BY lower(name)").all());
const exportMatch=url.pathname.match(/^\/api\/export\/(inventory|sales|customers|warranties|audit)$/);if(exportMatch&&req.method==="GET"){const type=exportMatch[1];if(type==="inventory"){const rows=listProducts().all().filter(x=>x.status==="available");return csvResponse(res,"vboxstock-inventory.csv",["Model","UID","Serial Number","MAC","Condition","Received","Cost","Notes"],rows.map(x=>[x.model,x.uid,x.sn,x.mac,x.condition,x.receivedAt,x.cost,x.notes]))}if(type==="sales"){const rows=listProducts().all().filter(x=>x.status==="sold");return csvResponse(res,"vboxstock-sales.csv",["Model","UID","Serial Number","MAC","Customer","Phone","Date Sold","Sale Price","Payment Method","Payment Reference","Fulfillment","Carrier","Tracking Number","Delivery Status","Warranty","Warranty End","Sale Notes"],rows.map(x=>[x.model,x.uid,x.sn,x.mac,x.customerName,x.phone,x.soldAt,x.salePrice,x.paymentMethod,x.paymentReference,x.fulfillmentMethod,x.carrier,x.trackingNumber,x.deliveryStatus,x.warrantyName,x.warrantyEndDate,x.saleNotes]))}if(type==="customers"){const rows=db.prepare("SELECT name,phone,address1,address2,city,state,zip,shipping_notes FROM customers ORDER BY lower(name)").all();return csvResponse(res,"vboxstock-customers.csv",["Name","Phone","Address 1","Address 2","City","State","ZIP","Notes"],rows.map(Object.values))}if(type==="warranties"){const rows=allWarranties();return csvResponse(res,"vboxstock-warranties.csv",["Name","Duration","Unit","Active","Default","Sales"],rows.map(x=>[x.name,x.durationValue,x.durationUnit,x.active,x.isDefault,x.usageCount]))}if(type==="audit"){if(user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});const rows=db.prepare("SELECT username,action,target,details,ip_address,created_at FROM audit_log ORDER BY id DESC").all();return csvResponse(res,"vboxstock-audit.csv",["Username","Action","Target","Details","IP Address","Created"],rows.map(Object.values))}}
if(url.pathname==="/api/products"&&req.method==="GET")return json(res,200,listProducts().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))} 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\/([^/]+)$/); const customerMatch=url.pathname.match(/^\/api\/customers\/([^/]+)$/);
@@ -136,8 +153,10 @@ async function api(req,res,url){
if(notesMatch){const customerId=decodeURIComponent(notesMatch[1]),noteId=notesMatch[2]?decodeURIComponent(notesMatch[2]):null;if(!db.prepare("SELECT id FROM customers WHERE id=?").get(customerId))return json(res,404,{error:"Customer not found"});if(req.method==="POST"&&!noteId){const v=await body(req),note=String(v.note||"").trim(),category=String(v.category||"General");if(!note)throw new Error("Note text is required.");if(!new Set(["General","Support","Follow-up"]).has(category))throw new Error("Invalid note category.");const id=crypto.randomUUID();db.prepare("INSERT INTO customer_notes (id,customer_id,category,note) VALUES (?,?,?,?)").run(id,customerId,category,note);audit(req,user,"customer_note_added",customerId,category);return json(res,201,{id,category,note})}if(req.method==="DELETE"&&noteId){db.prepare("DELETE FROM customer_notes WHERE id=? AND customer_id=?").run(noteId,customerId);audit(req,user,"customer_note_deleted",customerId,noteId);return json(res,204,null)}} if(notesMatch){const customerId=decodeURIComponent(notesMatch[1]),noteId=notesMatch[2]?decodeURIComponent(notesMatch[2]):null;if(!db.prepare("SELECT id FROM customers WHERE id=?").get(customerId))return json(res,404,{error:"Customer not found"});if(req.method==="POST"&&!noteId){const v=await body(req),note=String(v.note||"").trim(),category=String(v.category||"General");if(!note)throw new Error("Note text is required.");if(!new Set(["General","Support","Follow-up"]).has(category))throw new Error("Invalid note category.");const id=crypto.randomUUID();db.prepare("INSERT INTO customer_notes (id,customer_id,category,note) VALUES (?,?,?,?)").run(id,customerId,category,note);audit(req,user,"customer_note_added",customerId,category);return json(res,201,{id,category,note})}if(req.method==="DELETE"&&noteId){db.prepare("DELETE FROM customer_notes WHERE id=? AND customer_id=?").run(noteId,customerId);audit(req,user,"customer_note_deleted",customerId,noteId);return json(res,204,null)}}
const match=url.pathname.match(/^\/api\/products\/([^/]+)(?:\/(sell|restock))?$/);if(!match)return json(res,404,{error:"Not found"});const id=decodeURIComponent(match[1]),action=match[2],current=getProduct().get(id);if(!current)return json(res,404,{error:"Product not found"}); const match=url.pathname.match(/^\/api\/products\/([^/]+)(?:\/(sell|restock))?$/);if(!match)return json(res,404,{error:"Not found"});const id=decodeURIComponent(match[1]),action=match[2],current=getProduct().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);audit(req,user,"record_deleted",id,current.status);return json(res,204,null)} if(req.method==="DELETE"&&!action){db.prepare("DELETE FROM products WHERE id=?").run(id);audit(req,user,"record_deleted",id,current.status);return json(res,204,null)}
if(req.method==="PATCH"&&!action){if(current.status!=="sold")return json(res,400,{error:"Only sale records can be updated."});const v=await body(req),fulfillmentKeys=["fulfillmentMethod","fulfillmentName","shipAddress1","shipAddress2","shipCity","shipState","shipZip","fulfillmentNotes","carrier","trackingNumber","deliveryStatus"],hasFulfillmentUpdate=fulfillmentKeys.some(key=>Object.hasOwn(v,key)),f=hasFulfillmentUpdate?saleDetails(v,current):{method:current.fulfillmentMethod||"",name:current.fulfillmentName||"",address1:current.shipAddress1||"",address2:current.shipAddress2||"",city:current.shipCity||"",state:current.shipState||"",zip:current.shipZip||"",notes:current.fulfillmentNotes||current.shippingNotes||"",carrier:current.carrier||"",trackingNumber:current.trackingNumber||"",deliveryStatus:current.deliveryStatus||""};let warrantyPresetId=current.warrantyPresetId,warrantyName=current.warrantyName,warrantyEndDate=current.warrantyEndDate;if(Object.hasOwn(v,"warrantyPresetId")){const selectedId=String(v.warrantyPresetId),preset=db.prepare("SELECT id,name,duration_value,duration_unit,active FROM warranty_presets WHERE id=?").get(selectedId);if(!preset||(!preset.active&&selectedId!==current.warrantyPresetId))throw new Error("Select an active warranty period.");warrantyPresetId=preset.id;warrantyName=preset.name;warrantyEndDate=warrantyEnd(current.soldAt,preset)}db.prepare("UPDATE products SET sale_notes=?,fulfillment_method=?,fulfillment_name=?,ship_address1=?,ship_address2=?,ship_city=?,ship_state=?,ship_zip=?,shipping_notes=?,carrier=?,tracking_number=?,delivery_status=?,delivery_status_updated_at=CURRENT_TIMESTAMP,warranty_preset_id=?,warranty_name=?,warranty_end_date=? WHERE id=?").run(String(v.saleNotes??current.saleNotes??"").trim(),f.method,f.name,f.address1,f.address2,f.city,f.state,f.zip,f.notes,f.carrier,f.trackingNumber,f.deliveryStatus,warrantyPresetId,warrantyName,warrantyEndDate,id);audit(req,user,"sale_details_updated",id,`${f.method||"not recorded"}; ${f.deliveryStatus||"completed"}; ${warrantyName||"not recorded"}`);return json(res,200,getProduct().get(id))} if(req.method==="PATCH"&&!action){
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 paymentMethod=String(v.paymentMethod||"").trim();if(!new Set(["Cash","Venmo","PayPal"]).has(paymentMethod))throw new Error("Payment method must be Cash, Venmo, or PayPal.");const f=saleDetails(v),preset=v.warrantyPresetId?db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE id=? AND active=1").get(String(v.warrantyPresetId)):db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE active=1 AND is_default=1").get();if(!preset)throw new Error("Select an active warranty period.");const name=String(v.customerName).trim(),phone=String(v.phone||"").trim(),storeAddress=new Set(["Shipped","Installed At"]).has(f.method);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,storeAddress?f.address1:"",storeAddress?f.address2:"",storeAddress?f.city:"",storeAddress?f.state:"",storeAddress?f.zip:"",storeAddress?f.notes:"")}else if(storeAddress)db.prepare("UPDATE customers SET name=?,phone=?,address1=?,address2=?,city=?,state=?,zip=?,shipping_notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,f.address1,f.address2,f.city,f.state,f.zip,f.notes,customer.id);else db.prepare("UPDATE customers SET name=?,phone=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,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=?,payment_method=?,payment_reference=?,sale_notes=?,fulfillment_method=?,fulfillment_name=?,carrier=?,tracking_number=?,delivery_status=?,delivery_status_updated_at=CURRENT_TIMESTAMP,warranty_preset_id=?,warranty_name=?,warranty_end_date=? WHERE id=? AND status='available'").run(String(v.soldAt),customer.id,name,phone,Number(v.salePrice)||0,f.address1,f.address2,f.city,f.state,f.zip,f.notes,paymentMethod,String(v.paymentReference||"").trim(),String(v.saleNotes||"").trim(),f.method,f.name,f.carrier,f.trackingNumber,f.deliveryStatus,preset.id,preset.name,warrantyEnd(String(v.soldAt),preset),id);audit(req,user,"sale_recorded",id,`${name}; ${f.method}; ${preset.name}`);return json(res,200,getProduct().get(id))} if(current.status!=="sold")return json(res,400,{error:"Only sale records can be updated."});const v=await body(req),f=saleDetails(v,current),name=String(v.customerName??current.customerName??"").trim(),phone=String(v.phone??current.phone??"").trim(),soldAt=String(v.soldAt??current.soldAt??""),paymentMethod=String(v.paymentMethod??current.paymentMethod??"").trim(),salePrice=Number(v.salePrice??current.salePrice)||0;if(!name||!soldAt)throw new Error("Customer name and sale date are required.");if(!new Set(["Cash","Venmo","PayPal"]).has(paymentMethod))throw new Error("Payment method must be Cash, Venmo, or PayPal.");let customer=resolveCustomer({...v,customerName:name,phone});if(!customer){customer={id:crypto.randomUUID()};addCustomer.run(customer.id,name,phone,"","","","","","")}saveCustomer(customer.id,name,phone,f);const selectedId=String(v.warrantyPresetId??current.warrantyPresetId??""),preset=db.prepare("SELECT id,name,duration_value,duration_unit,active FROM warranty_presets WHERE id=?").get(selectedId);if(!preset||(!preset.active&&selectedId!==current.warrantyPresetId))throw new Error("Select an active warranty period.");db.prepare("UPDATE products SET sold_at=?,customer_id=?,customer_name=?,phone=?,sale_price=?,payment_method=?,payment_reference=?,sale_notes=?,fulfillment_method=?,fulfillment_name=?,ship_address1=?,ship_address2=?,ship_city=?,ship_state=?,ship_zip=?,shipping_notes=?,carrier=?,tracking_number=?,delivery_status=?,delivery_status_updated_at=CURRENT_TIMESTAMP,warranty_preset_id=?,warranty_name=?,warranty_end_date=? WHERE id=?").run(soldAt,customer.id,name,phone,salePrice,paymentMethod,String(v.paymentReference??current.paymentReference??"").trim(),String(v.saleNotes??current.saleNotes??"").trim(),f.method,f.name,f.address1,f.address2,f.city,f.state,f.zip,f.notes,f.carrier,f.trackingNumber,f.deliveryStatus,preset.id,preset.name,warrantyEnd(soldAt,preset),id);audit(req,user,"sale_details_updated",id,`${name}; ${paymentMethod}; ${f.method}; ${preset.name}`);return json(res,200,getProduct().get(id))
}
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 paymentMethod=String(v.paymentMethod||"").trim();if(!new Set(["Cash","Venmo","PayPal"]).has(paymentMethod))throw new Error("Payment method must be Cash, Venmo, or PayPal.");const f=saleDetails(v),preset=v.warrantyPresetId?db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE id=? AND active=1").get(String(v.warrantyPresetId)):db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE active=1 AND is_default=1").get();if(!preset)throw new Error("Select an active warranty period.");const name=String(v.customerName).trim(),phone=String(v.phone||"").trim();let customer=resolveCustomer(v);if(!customer){customer={id:crypto.randomUUID()};addCustomer.run(customer.id,name,phone,"","","","","","")}saveCustomer(customer.id,name,phone,f);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=?,payment_method=?,payment_reference=?,sale_notes=?,fulfillment_method=?,fulfillment_name=?,carrier=?,tracking_number=?,delivery_status=?,delivery_status_updated_at=CURRENT_TIMESTAMP,warranty_preset_id=?,warranty_name=?,warranty_end_date=? WHERE id=? AND status='available'").run(String(v.soldAt),customer.id,name,phone,Number(v.salePrice)||0,f.address1,f.address2,f.city,f.state,f.zip,f.notes,paymentMethod,String(v.paymentReference||"").trim(),String(v.saleNotes||"").trim(),f.method,f.name,f.carrier,f.trackingNumber,f.deliveryStatus,preset.id,preset.name,warrantyEnd(String(v.soldAt),preset),id);audit(req,user,"sale_recorded",id,`${name}; ${f.method}; ${preset.name}`);return json(res,200,getProduct().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='',payment_method='',payment_reference='',sale_notes='',fulfillment_method='',fulfillment_name='',carrier='',tracking_number='',delivery_status='',delivery_status_updated_at=NULL,warranty_preset_id=NULL,warranty_name='',warranty_end_date=NULL WHERE id=? AND status='sold'").run(v.condition,String(v.receivedAt),id);audit(req,user,"sale_voided_restocked",id);return json(res,200,getProduct().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='',payment_method='',payment_reference='',sale_notes='',fulfillment_method='',fulfillment_name='',carrier='',tracking_number='',delivery_status='',delivery_status_updated_at=NULL,warranty_preset_id=NULL,warranty_name='',warranty_end_date=NULL WHERE id=? AND status='sold'").run(v.condition,String(v.receivedAt),id);audit(req,user,"sale_voided_restocked",id);return json(res,200,getProduct().get(id))}
return json(res,405,{error:"Method not allowed"}); return json(res,405,{error:"Method not allowed"});
} }