Release v3.2.0 fulfillment and warranty tracking
This commit is contained in:
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# 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
|
## 3.1.0
|
||||||
|
|
||||||
- Added an Admin-managed product model catalog.
|
- Added an Admin-managed product model catalog.
|
||||||
|
|||||||
@@ -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.
|
1. Receive an individually identifiable device into inventory.
|
||||||
2. Record its cost, model, condition, and notes.
|
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.
|
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.
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -47,6 +50,14 @@ General inventory tools can be larger and more complicated than a small reseller
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Fulfillment and warranty tracking
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Warranty administration
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### User administration and database backups
|
### User administration and database backups
|
||||||
|
|
||||||

|

|
||||||
@@ -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 |
|
| View and search inventory, sales, customers, and notes | Yes | Yes |
|
||||||
| Receive inventory and record sales | Yes | No |
|
| 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 |
|
| Edit notes, void sales, or delete records | Yes | No |
|
||||||
| Manage product models, users, and view the audit log | Yes | No |
|
| Manage product models, users, and view the audit log | Yes | No |
|
||||||
| Create, download, delete, or restore backups | 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.
|
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`.
|
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.
|
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.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ x-casaos:
|
|||||||
category: Productivity
|
category: Productivity
|
||||||
architectures:
|
architectures:
|
||||||
- amd64
|
- amd64
|
||||||
version: "3.1.0"
|
version: "3.2.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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vboxstock",
|
"name": "vboxstock",
|
||||||
"version": "3.1.0",
|
"version": "3.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=22.13.0" },
|
"engines": { "node": ">=22.13.0" },
|
||||||
|
|||||||
+25
-7
@@ -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});
|
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
|
||||||
let db=new DatabaseSync(databasePath);
|
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");
|
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(){
|
function initializeDatabase(){
|
||||||
db.exec(`
|
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 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);`);
|
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 ''"]])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()){
|
if(legacyProductSchema()){
|
||||||
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
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 '');
|
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;
|
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")}
|
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);
|
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 (?,?)");
|
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 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);
|
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")}`};
|
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}}
|
||||||
@@ -48,7 +55,7 @@ for(const old of db.prepare("SELECT DISTINCT customer_name name,phone FROM produ
|
|||||||
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)}
|
||||||
|
|
||||||
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 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`);
|
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)}
|
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 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 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 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){
|
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)})}
|
||||||
@@ -84,6 +97,10 @@ 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/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==="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})}
|
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\/([^/]+)$/);
|
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}
|
if(url.pathname.startsWith("/api/admin/")){const handled=await adminApi(req,res,url,session);if(handled!==false)return handled}
|
||||||
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/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\/([^/]+)$/);
|
||||||
@@ -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)}}
|
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"});
|
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:"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==="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 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==="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='' 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"});
|
||||||
}
|
}
|
||||||
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"};
|
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"};
|
||||||
|
|||||||
Reference in New Issue
Block a user