From 574e1244bfb18227c8120ef517c3bc7feb1ee451 Mon Sep 17 00:00:00 2001 From: mfwadejr <125498560+mfwadejr@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:47:18 -0400 Subject: [PATCH] Release v3.2.0 fulfillment and warranty tracking --- CHANGELOG.md | 12 ++++++++++++ README.md | 24 ++++++++++++++++++++++-- docker-compose.zimaos.yml | 2 +- package.json | 2 +- server.mjs | 34 ++++++++++++++++++++++++++-------- 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e591c..111880c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 3.2.0 + +- Added sale fulfillment methods for Shipped, Dropped Off, Installed At, and Meet with context-sensitive fields and validation. +- Added UPS, FedEx, USPS, and Other carrier records, optional tracking numbers, official carrier tracking links, and editable delivery status. +- Added Admin-managed warranty periods with custom day, month, or year durations, a configurable default, archival, usage protection, and historical sale snapshots. +- Added green in-warranty countdowns and red expired indicators to sales, sale details, and customer purchase history. +- Added post-sale editing for fulfillment, tracking, delivery status, warranty, and transaction notes. +- Kept meetup and drop-off locations separate from permanent customer addresses; only shipping and installation update the customer address. +- Cleared fulfillment and warranty data when a sale is voided and its product is restocked. +- Improved mobile sign-in and form behavior on iPhone and iPad by preventing focus zoom and horizontal overflow without disabling user zoom. +- Added migration safety backups and automated coverage for warranty and fulfillment behavior. + ## 3.1.0 - Added an Admin-managed product model catalog. diff --git a/README.md b/README.md index 6769eb4..abbc6fc 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ General inventory tools can be larger and more complicated than a small reseller 1. Receive an individually identifiable device into inventory. 2. Record its cost, model, condition, and notes. -3. Complete a sale with customer, payment, shipping, and transaction details. +3. Complete a sale with customer, payment, fulfillment, warranty, and transaction details. 4. Revisit the customer or sale later for support and follow-up. 5. Back up the complete business record without managing a separate database server. @@ -26,7 +26,10 @@ General inventory tools can be larger and more complicated than a small reseller - Start with vSeeBox V3 Plus, V5 Pro, V6 Plus, and V6 Pro, then add any additional model you carry. - Manage the model catalog from the Admin page: rename unused models, archive end-of-life models, reactivate them later, or delete models that have never been used. - Record New, Used, or Refurbished condition and purchase cost. -- Capture customer name, phone number, shipped-to address, and shipping notes. +- Record whether a product was shipped, dropped off, installed, or exchanged at a meetup using fields tailored to that method. +- Capture shipping carriers, tracking numbers, direct official tracking links, and an editable delivery status. +- Configure warranty periods in Admin and see live green in-warranty countdowns or red expired indicators throughout sale history. +- Keep shipped-to and installed-at addresses while allowing venue or notes-based details for drop-offs and meetups. - Record Cash, Venmo, or PayPal payments with an optional reference. - Attach transaction notes to a sale and time-stamped support notes to a customer. - Browse inventory, sales, and customers in searchable 10-record pages. @@ -47,6 +50,14 @@ General inventory tools can be larger and more complicated than a small reseller ![vBoxStock product model administration](docs/screenshots/admin.jpg) +### Fulfillment and warranty tracking + +![vBoxStock sale fulfillment and warranty details](docs/screenshots/sale-fulfillment-warranty.png) + +### Warranty administration + +![vBoxStock warranty period administration](docs/screenshots/admin-warranties.png) + ### User administration and database backups ![vBoxStock user administration and database backups](docs/screenshots/admin-users-backups.jpg) @@ -68,6 +79,7 @@ vBoxStock immediately requires a new password and blocks access to application d | --- | :---: | :---: | | View and search inventory, sales, customers, and notes | Yes | Yes | | Receive inventory and record sales | Yes | No | +| Edit fulfillment, tracking, warranty, and transaction details | Yes | No | | Edit notes, void sales, or delete records | Yes | No | | Manage product models, users, and view the audit log | Yes | No | | Create, download, delete, or restore backups | Yes | No | @@ -203,6 +215,14 @@ Administrators manage product models from **Admin → Product models**. Active m Model names are unique regardless of capitalization and may contain up to 60 characters. An unused model can be renamed or permanently deleted. Once a model has been used by an inventory or sales record, its name is preserved for historical accuracy; archive it and create a new model instead of renaming or deleting it. The Admin page displays separate available and sold usage counts before an archive is confirmed. +### Fulfillment and warranty records + +Every new sale records a delivery method: **Shipped**, **Dropped Off**, **Installed At**, or **Meet**. Shipping and installation require an address. Drop-off and meetup records accept a venue, an optional address, or a descriptive fulfillment note, so locations such as a store or gas station do not become the customer's permanent address. Only Shipped and Installed At sales update the address shown on the customer record. + +Shipped sales support UPS, FedEx, USPS, or Other, an optional tracking number, and a manually maintained delivery status. Recognized carriers receive a direct link to their official tracking page. Carrier websites are not scraped and delivery statuses are not fetched automatically. + +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`. 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. diff --git a/docker-compose.zimaos.yml b/docker-compose.zimaos.yml index 83ca767..6bc8525 100644 --- a/docker-compose.zimaos.yml +++ b/docker-compose.zimaos.yml @@ -36,7 +36,7 @@ x-casaos: category: Productivity architectures: - amd64 - version: "3.1.0" + version: "3.2.0" update_at: "2026-08-30" release_notes: en_US: Added administrator-managed product models with archival and historical preservation. diff --git a/package.json b/package.json index f3d1541..8d9204b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vboxstock", - "version": "3.1.0", + "version": "3.2.0", "private": true, "type": "module", "engines": { "node": ">=22.13.0" }, diff --git a/server.mjs b/server.mjs index 3ba2b64..5aadc54 100644 --- a/server.mjs +++ b/server.mjs @@ -11,7 +11,8 @@ 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`)); +const needsSaleMigration=()=>{const exists=db.prepare("SELECT 1 found FROM sqlite_master WHERE type='table' AND name='products'").get();return exists&&!db.prepare("PRAGMA table_info(products)").all().some(c=>c.name==="fulfillment_method")}; +if(legacyProductSchema()||needsSaleMigration())await backup(db,join(backupDir,`pre-v3-2-upgrade-${new Date().toISOString().replace(/[:.]/g,"-")}.db`)); function initializeDatabase(){ db.exec(` @@ -23,18 +24,24 @@ function initializeDatabase(){ 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 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}`); + 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}`); 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")} - } + } + const finalCols=new Set(db.prepare("PRAGMA table_info(products)").all().map(c=>c.name)); + for(const [name,definition] of [["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(!finalCols.has(name))db.exec(`ALTER TABLE products ADD COLUMN ${name} ${definition}`); 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);`); + 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); + CREATE TABLE IF NOT EXISTS warranty_presets (id TEXT PRIMARY KEY,name TEXT NOT NULL COLLATE NOCASE UNIQUE,duration_value INTEGER NOT NULL DEFAULT 0 CHECK(duration_value>=0),duration_unit TEXT NOT NULL DEFAULT 'days' CHECK(duration_unit IN ('days','months','years')),active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),is_default INTEGER NOT NULL DEFAULT 0 CHECK(is_default 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 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); + 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 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}} @@ -48,7 +55,7 @@ for(const old of db.prepare("SELECT DISTINCT customer_name name,phone FROM produ 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 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 fulfillmentNotes,shipping_notes AS shippingNotes,payment_method AS paymentMethod,payment_reference AS paymentReference,sale_notes AS saleNotes,fulfillment_method AS fulfillmentMethod,fulfillment_name AS fulfillmentName,carrier,tracking_number AS trackingNumber,delivery_status AS deliveryStatus,delivery_status_updated_at AS deliveryStatusUpdatedAt,warranty_preset_id AS warrantyPresetId,warranty_name AS warrantyName,warranty_end_date AS warrantyEndDate"; 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)} @@ -73,6 +80,12 @@ function cleanModelName(value){const name=String(value||"").trim().replace(/\s+/ 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)}))} +function cleanWarrantyName(value){const name=String(value||"").trim().replace(/\s+/g," ");if(!name||name.length>60)throw new Error("Warranty name must contain 1–60 characters.");return name} +function warrantyUsage(id){return db.prepare("SELECT w.*,COUNT(p.id) usageCount FROM warranty_presets w LEFT JOIN products p ON p.warranty_preset_id=w.id WHERE w.id=? GROUP BY w.id").get(id)} +function allWarranties(){return db.prepare("SELECT w.id,w.name,w.duration_value AS durationValue,w.duration_unit AS durationUnit,w.active,w.is_default AS isDefault,COUNT(p.id) usageCount FROM warranty_presets w LEFT JOIN products p ON p.warranty_preset_id=w.id GROUP BY w.id ORDER BY w.duration_value,w.name").all().map(x=>({...x,active:Boolean(x.active),isDefault:Boolean(x.isDefault)}))} +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"]); +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}} 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)})} @@ -84,6 +97,10 @@ async function authApi(req,res,url,session){ async function adminApi(req,res,url,session){ const user=session.user; + 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})} + const warrantyMatch=url.pathname.match(/^\/api\/admin\/warranties\/([^/]+)$/); + if(warrantyMatch){const id=decodeURIComponent(warrantyMatch[1]),target=warrantyUsage(id);if(!target)return json(res,404,{error:"Warranty period not found"});if(req.method==="PATCH"){const v=await body(req);if(Object.hasOwn(v,"isDefault")&&v.isDefault){db.exec("UPDATE warranty_presets SET is_default=0");db.prepare("UPDATE warranty_presets SET is_default=1,active=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(id);audit(req,user,"warranty_default_changed",target.name);return json(res,200,{ok:true})}if(Object.hasOwn(v,"active")){const active=Boolean(v.active);if(target.is_default&&!active)throw new Error("Choose another default warranty before archiving this one.");db.prepare("UPDATE warranty_presets SET active=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(active?1:0,id);audit(req,user,active?"warranty_reactivated":"warranty_archived",target.name);return json(res,200,{ok:true})}if(target.usageCount)throw new Error("A warranty period used by a sale cannot be changed. Archive it and create a new period instead.");const name=cleanWarrantyName(v.name??target.name),durationValue=Number(v.durationValue??target.duration_value),durationUnit=String(v.durationUnit??target.duration_unit);if(!Number.isInteger(durationValue)||durationValue<0||durationValue>3650||!new Set(["days","months","years"]).has(durationUnit))throw new Error("Enter a valid warranty duration.");db.prepare("UPDATE warranty_presets SET name=?,duration_value=?,duration_unit=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,durationValue,durationUnit,id);audit(req,user,"warranty_updated",target.name,`${name}; ${durationValue} ${durationUnit}`);return json(res,200,{ok:true})}if(req.method==="DELETE"){if(target.usageCount)throw new Error("A warranty period used by a sale cannot be deleted. Archive it instead.");if(target.is_default)throw new Error("The default warranty cannot be deleted.");db.prepare("DELETE FROM warranty_presets WHERE id=?").run(id);audit(req,user,"warranty_deleted",target.name);return json(res,204,null)}} 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\/([^/]+)$/); @@ -110,6 +127,7 @@ async function api(req,res,url){ 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/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/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\/([^/]+)$/); @@ -118,9 +136,9 @@ 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"&¬eId){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"}); 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:"Transaction notes can only be added to sales."});const v=await body(req);db.prepare("UPDATE products SET sale_notes=? WHERE id=?").run(String(v.saleNotes||"").trim(),id);audit(req,user,"sale_notes_updated",id);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 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=?,payment_method=?,payment_reference=?,sale_notes=? WHERE id=? AND status='available'").run(String(v.soldAt),customer.id,name,phone,Number(v.salePrice)||0,address1,address2,city,state,zip,shippingNotes,paymentMethod,String(v.paymentReference||"").trim(),String(v.saleNotes||"").trim(),id);audit(req,user,"sale_recorded",id,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='' 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==="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==="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(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"}); } 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"};