diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ae06a..4624914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 3.3.1 + +- Moved automatic-backup scheduling into the Database backups section. +- Renamed the control to **Enable automatic backups**. +- Added Every day and Weekly frequencies with a conditional weekday selector. +- Replaced the numeric hour field with a clearer local-time selection. +- Added last and next automatic-backup status information. +- Renamed new scheduled files to `automatic-*.db` while retaining cleanup compatibility for earlier `scheduled-*.db` files. +- Kept automatic retention isolated from manual, pre-upgrade, and pre-restore backups. + ## 3.3.0 - Added returning-customer autocomplete and server-side duplicate safeguards using customer IDs, normalized names, and phone numbers. diff --git a/README.md b/README.md index 65ccdbc..e4a151e 100644 --- a/README.md +++ b/README.md @@ -221,9 +221,9 @@ A fresh installation creates an empty inventory, sales history, and customer lis The Admin page can create a transactionally consistent snapshot, download it to another device, restore a local snapshot, or upload and restore a downloaded copy. A pre-restore snapshot is created automatically before the active database is replaced. -Daily automatic backups can be enabled under **Admin → System and data tools**. Choose a local-time hour from `0` through `23` and retain between 1 and 365 scheduled snapshots. Retention applies only to files named `scheduled-*.db`; manual, pre-upgrade, and pre-restore backups are never removed automatically. The application checks the schedule every 15 minutes and creates at most one scheduled backup per calendar day. +Automatic backups can be enabled under **Admin → Database backups**. Choose **Every day** or **Weekly**; weekly schedules also provide a weekday selection. Select the local backup time and retain between 1 and 365 automatic snapshots. Retention applies only to `automatic-*.db` files and legacy `scheduled-*.db` files; manual, pre-upgrade, and pre-restore backups are never removed automatically. The application checks the schedule every 15 minutes and shows the last and next automatic-backup times. -The same section reports application and database-schema versions, Node.js version, database size, `/data` writability, free disk space, configured time zone, and the latest backup. These checks are local to the container and do not transmit system information anywhere. +The separate **System diagnostics** section reports application and database-schema versions, Node.js version, database size, `/data` writability, free disk space, configured time zone, and the latest backup. These checks are local to the container and do not transmit system information anywhere. ## Product model catalog diff --git a/docker-compose.zimaos.yml b/docker-compose.zimaos.yml index 4a269ac..8c2f06e 100644 --- a/docker-compose.zimaos.yml +++ b/docker-compose.zimaos.yml @@ -36,7 +36,7 @@ x-casaos: category: Productivity architectures: - amd64 - version: "3.3.0" + version: "3.3.1" update_at: "2026-08-30" release_notes: en_US: Added administrator-managed product models with archival and historical preservation. diff --git a/docs/screenshots/admin-system-backups.png b/docs/screenshots/admin-system-backups.png index 008e490..c10445e 100644 Binary files a/docs/screenshots/admin-system-backups.png and b/docs/screenshots/admin-system-backups.png differ diff --git a/package.json b/package.json index 099a12e..0f28ab1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vboxstock", - "version": "3.3.0", + "version": "3.3.1", "private": true, "type": "module", "engines": { "node": ">=22.13.0" }, diff --git a/public/app.js b/public/app.js index 3d18384..d08516e 100644 --- a/public/app.js +++ b/public/app.js @@ -111,7 +111,8 @@ async function renderAdmin(){ const panel=$("#adminPanel"); panel.innerHTML='
Loading administration…
'; try{const [backups,users,audit,diagnostics]=await Promise.all([api("/api/admin/backups"),api("/api/admin/users"),api("/api/admin/audit"),api("/api/admin/diagnostics")]);panel.innerHTML=`

User accounts

Create administrators or read-only accounts. New and reset passwords must be changed at next login.

${users.map(u=>`
${esc(u.username)}${u.role==="admin"?"Admin":"Read-Only"} · ${u.enabled?"Enabled":"Disabled"}${u.mustChangePassword?" · Password change required":""}${u.lastLoginAt?` · Last login ${fmtDateTime(u.lastLoginAt)}`:""}
`).join("")}

Database backups

Create snapshots inside /data/backups, download an off-server copy, or restore a previous database.

Restore replaces the active database and all user accounts. Your current password is required. Everyone will be signed out afterward.
${backups.length?backups.map(b=>`
${esc(b.name)}${fmtDateTime(b.createdAt)} · ${(b.size/1024).toFixed(1)} KB
Download
`).join(""):"

No local backups yet.

"}

Audit log

The latest 250 security and data-changing events. Audit entries cannot be edited or deleted.

${audit.map(a=>`
${esc(a.username)}${esc(a.action.replaceAll("_"," "))}${fmtDateTime(a.createdAt)}${a.target?` · ${esc(a.target)}`:""}${a.details?` · ${esc(a.details)}`:""}${a.ipAddress?` · ${esc(a.ipAddress)}`:""}
`).join("")||"

No audit events yet.

"}
`; panel.querySelector(".admin-page").insertAdjacentHTML("afterbegin",'
vBoxStock

Administration

Manage product models, access, data protection, and account activity.

'); - const bytes=n=>n>1073741824?`${(n/1073741824).toFixed(1)} GB`:n>1048576?`${(n/1048576).toFixed(1)} MB`:`${(n/1024).toFixed(1)} KB`,systemSection=document.createElement("section");systemSection.className="admin-section system-section";systemSection.innerHTML=`

System and data tools

vBoxStock ${esc(diagnostics.appVersion)} · Database schema ${diagnostics.schemaVersion} · ${esc(diagnostics.nodeVersion)}

Database${bytes(diagnostics.databaseSize)}${diagnostics.dataWritable?"/data is writable":"/data is not writable"}
Storage available${bytes(diagnostics.diskFree)}of ${bytes(diagnostics.diskTotal)}
Backups${diagnostics.backupCount}${diagnostics.lastBackupAt?`Latest ${fmtDateTime(diagnostics.lastBackupAt)}`:"None created"}
Time zone${esc(diagnostics.timeZone)}${esc(diagnostics.dataDirectory)}
`;panel.querySelector(".admin-intro").insertAdjacentElement("afterend",systemSection);$("#backupScheduleForm").onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.currentTarget));try{await api("/api/admin/backup-settings",{method:"PATCH",body:JSON.stringify({enabled:Boolean(data.enabled),hour:Number(data.hour),retention:Number(data.retention)})});toast("Backup schedule saved.");renderAdmin()}catch(error){toast(error.message)}}; + const bytes=n=>n>1073741824?`${(n/1073741824).toFixed(1)} GB`:n>1048576?`${(n/1048576).toFixed(1)} MB`:`${(n/1024).toFixed(1)} KB`,systemSection=document.createElement("section");systemSection.className="admin-section system-section";systemSection.innerHTML=`

System diagnostics

vBoxStock ${esc(diagnostics.appVersion)} · Database schema ${diagnostics.schemaVersion} · ${esc(diagnostics.nodeVersion)}

Database${bytes(diagnostics.databaseSize)}${diagnostics.dataWritable?"/data is writable":"/data is not writable"}
Storage available${bytes(diagnostics.diskFree)}of ${bytes(diagnostics.diskTotal)}
Backups${diagnostics.backupCount}${diagnostics.lastBackupAt?`Latest ${fmtDateTime(diagnostics.lastBackupAt)}`:"None created"}
Time zone${esc(diagnostics.timeZone)}${esc(diagnostics.dataDirectory)}
`;panel.querySelector(".admin-intro").insertAdjacentElement("afterend",systemSection); + const backupSection=[...panel.querySelectorAll(".admin-section")].find(section=>section.querySelector("h2")?.textContent==="Database backups"),schedule=document.createElement("div"),weekdays=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];schedule.className="automatic-backups";schedule.innerHTML=`

Automatic backups

Run a retained local backup every day or once per week. Manual, pre-upgrade, and pre-restore backups are never removed automatically.

Last automatic backup: ${diagnostics.backupSettings.lastScheduledBackup?fmtDate(diagnostics.backupSettings.lastScheduledBackup):"Not yet run"}Next automatic backup: ${diagnostics.backupSettings.nextScheduledBackup?fmtDateTime(diagnostics.backupSettings.nextScheduledBackup):"Disabled"}
`;backupSection.querySelector(".backup-warning").insertAdjacentElement("beforebegin",schedule);const frequency=$("#backupScheduleForm [name=frequency]"),weekly=$("#backupScheduleForm .weekly-field"),updateFrequency=()=>weekly.hidden=frequency.value!=="weekly";frequency.onchange=updateFrequency;updateFrequency();$("#backupScheduleForm").onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.currentTarget));try{await api("/api/admin/backup-settings",{method:"PATCH",body:JSON.stringify({enabled:Boolean(data.enabled),frequency:data.frequency,weekday:Number(data.weekday),hour:Number(data.hour),retention:Number(data.retention)})});toast("Automatic backup schedule saved.");renderAdmin()}catch(error){toast(error.message)}}; const models=await api("/api/admin/models"),modelSection=document.createElement("section");modelSection.className="admin-section model-section";modelSection.innerHTML=`

Product models

Active models are available when receiving products. Archiving removes a model from new receiving while preserving inventory and sales history.

${models.map(m=>`
${esc(m.name)}${m.active?"Active":"Archived"}
${m.availableCount} available · ${m.soldCount} sold
${m.totalCount===0?``:""}${m.totalCount===0?``:""}
`).join("")||"

No models configured.

"}
`;panel.querySelector(".admin-intro").insertAdjacentElement("afterend",modelSection); const finishModelChange=async message=>{state.models=await api("/api/models");toast(message);renderAdmin();}; $("#createModelForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/models",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await finishModelChange("Model added.");}catch(error){toast(error.message);}}; diff --git a/public/extras.css b/public/extras.css index b67fa60..ae7de3c 100644 --- a/public/extras.css +++ b/public/extras.css @@ -7,4 +7,4 @@ .auth-pending #appShell,.auth-required #appShell{display:none}.role-admin #authScreen,.role-readonly #authScreen{display:none}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:24px;background:linear-gradient(145deg,#eef4ff,#f8fafc)}.auth-card{width:min(430px,100%);background:#fff;border:1px solid #dfe5ef;border-radius:18px;box-shadow:0 22px 60px #17203320;padding:34px}.auth-brand{margin-bottom:28px}.auth-card h1{margin:0 0 7px}.auth-card>div>p{color:#68758b}.auth-submit{width:100%;margin-top:14px}.auth-message{background:#fff0f0;color:#a51d1d;border:1px solid #fecaca;border-radius:8px;padding:10px 12px;margin:14px 0}.auth-logout{width:100%;margin-top:10px}.user-menu{display:flex;align-items:center;gap:10px;border-left:1px solid #e3e7ef;padding-left:18px}.user-menu span{display:grid;font-size:13px}.user-menu small{color:#758197}.admin-section{display:grid;gap:16px;border-bottom:1px solid #e3e7ef;padding-bottom:28px}.admin-section:last-child{border-bottom:0}.inline-user-form{display:grid;grid-template-columns:1fr 1fr 180px auto;gap:10px;align-items:end}.inline-user-form label{margin:0}.user-list,.audit-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.user-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.user-list article:last-child{border-bottom:0}.user-list small{display:block;color:#7b8799;margin-top:4px}.user-list article>div:last-child{display:flex;gap:7px;flex-wrap:wrap}.delete-backup{background:#fff0f0;color:#c62828}.audit-list{max-height:420px;overflow:auto}.audit-list article{display:grid;grid-template-columns:140px 180px 1fr;gap:12px;padding:10px 13px;border-bottom:1px solid #edf0f5;font-size:13px}.audit-list span{text-transform:capitalize}.audit-list small{color:#7b8799}@media(max-width:900px){.inline-user-form{grid-template-columns:1fr 1fr}.user-list article{align-items:flex-start;flex-direction:column}.audit-list article{grid-template-columns:1fr}.user-menu span{display:none}}@media(max-width:600px){.inline-user-form{grid-template-columns:1fr}.user-menu{padding-left:5px}.user-menu button{font-size:12px;padding:8px}.auth-card{padding:25px}} .inline-model-form{display:grid;grid-template-columns:minmax(220px,1fr) auto;gap:10px;align-items:end}.inline-model-form label{margin:0}.model-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.model-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.model-list article:last-child{border-bottom:0}.model-summary>div{display:flex;align-items:center;gap:9px}.model-summary small{display:block;color:#7b8799;margin-top:5px}.model-status{display:inline-flex;border-radius:99px;padding:3px 8px;font-size:11px;font-weight:750}.model-status.active{background:#dcfce7;color:#166534}.model-status.archived{background:#eef1f5;color:#526071}.model-actions{display:flex;gap:7px;flex-wrap:wrap}@media(max-width:760px){.inline-model-form{grid-template-columns:1fr}.model-list article{align-items:stretch;flex-direction:column}.model-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.model-actions button{text-align:center}} .conditional-fields{margin:2px 0 14px;padding:16px;border:1px solid #e3e7ef;border-radius:11px;background:#f8fafc}.conditional-fields h3{margin:0 0 12px}.conditional-hint{margin:4px 0 16px;padding:13px;border:1px dashed #d7deea;border-radius:9px;color:#68758b}.field-help{display:block;margin-top:-8px;color:#68758b}.warranty-pill{display:inline-flex;align-items:center;border-radius:99px;padding:5px 9px;font-size:12px;font-weight:750;white-space:nowrap}.warranty-pill.in-warranty{background:#dcfce7;color:#166534}.warranty-pill.expired{background:#fee2e2;color:#b91c1c}.warranty-pill.neutral{background:#eef1f5;color:#526071}.inline-warranty-form{display:grid;grid-template-columns:minmax(180px,1fr) 130px 150px auto;gap:10px;align-items:end}.inline-warranty-form label{margin:0}.warranty-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.warranty-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.warranty-list article:last-child{border-bottom:0}@media(max-width:760px){.inline-warranty-form{grid-template-columns:1fr}.warranty-list article{align-items:stretch;flex-direction:column}} -.filter-bar{display:flex;align-items:end;gap:9px;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e3e7ef}.filter-bar label{margin:0;min-width:125px;font-size:11px}.filter-bar select,.filter-bar input{padding:8px 9px;font-size:13px}.filter-bar .button,.filter-bar button{padding:9px 12px}.export-button{text-decoration:none}.customer-match{min-height:18px;margin:-7px 0 10px!important;color:#1d4ed8!important;font-size:12px}.diagnostic-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.diagnostic-grid article{display:grid;gap:4px;padding:14px;border:1px solid #e3e7ef;border-radius:10px}.diagnostic-grid span,.diagnostic-grid small{color:#68758b}.diagnostic-grid strong{font-size:18px}.backup-schedule{display:grid;grid-template-columns:minmax(220px,1fr) 180px 200px auto;gap:10px;align-items:end}.backup-schedule label{margin:0}.check-label{display:flex!important;align-items:center;gap:8px;min-height:42px}.check-label input{width:auto}.export-links{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.export-links .button{padding:8px 11px;text-decoration:none}@media(max-width:900px){.diagnostic-grid{grid-template-columns:1fr 1fr}.backup-schedule{grid-template-columns:1fr 1fr}}@media(max-width:600px){.filter-bar{align-items:stretch}.filter-bar label,.filter-bar .button,.filter-bar button{width:100%}.diagnostic-grid,.backup-schedule{grid-template-columns:1fr}} +.filter-bar{display:flex;align-items:end;gap:9px;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e3e7ef}.filter-bar label{margin:0;min-width:125px;font-size:11px}.filter-bar select,.filter-bar input{padding:8px 9px;font-size:13px}.filter-bar .button,.filter-bar button{padding:9px 12px}.export-button{text-decoration:none}.customer-match{min-height:18px;margin:-7px 0 10px!important;color:#1d4ed8!important;font-size:12px}.diagnostic-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.diagnostic-grid article{display:grid;gap:4px;padding:14px;border:1px solid #e3e7ef;border-radius:10px}.diagnostic-grid span,.diagnostic-grid small{color:#68758b}.diagnostic-grid strong{font-size:18px}.automatic-backups{display:grid;gap:12px;padding:16px;border:1px solid #e3e7ef;border-radius:10px}.automatic-backups h3,.automatic-backups p{margin:0}.backup-schedule{display:grid;grid-template-columns:minmax(190px,1.3fr) repeat(4,minmax(130px,1fr)) auto;gap:10px;align-items:end}.backup-schedule label{margin:0}.weekly-field[hidden]{display:none!important}.check-label{display:flex!important;align-items:center;gap:8px;min-height:42px}.check-label input{width:auto}.backup-schedule-status{display:flex;gap:18px;flex-wrap:wrap;font-size:13px}.export-links{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.export-links .button{padding:8px 11px;text-decoration:none}@media(max-width:1100px){.backup-schedule{grid-template-columns:1fr 1fr 1fr}}@media(max-width:900px){.diagnostic-grid{grid-template-columns:1fr 1fr}.backup-schedule{grid-template-columns:1fr 1fr}}@media(max-width:600px){.filter-bar{align-items:stretch}.filter-bar label,.filter-bar .button,.filter-bar button{width:100%}.diagnostic-grid,.backup-schedule{grid-template-columns:1fr}} diff --git a/public/theme.css b/public/theme.css index f390991..816b73e 100644 --- a/public/theme.css +++ b/public/theme.css @@ -26,7 +26,7 @@ th{background:var(--surface-table);color:var(--muted-2)}th,td,.records nav,.back .customer-notes article,.backup-list,.user-list,.model-list,.warranty-list,.audit-list{border-color:var(--border)} .model-list article{border-color:var(--border)}.model-summary small{color:var(--muted)}.model-status.active{background:var(--green-soft);color:var(--green)}.model-status.archived{background:var(--surface-soft);color:var(--muted)} .warranty-list article,.conditional-fields{border-color:var(--border)}.conditional-fields{background:var(--surface-soft)}.conditional-hint{border-color:var(--border-strong);color:var(--muted)}.field-help{color:var(--muted)}.warranty-pill.in-warranty{background:var(--green-soft);color:var(--green)}.warranty-pill.expired{background:var(--danger-soft);color:var(--danger)}.warranty-pill.neutral{background:var(--surface-soft);color:var(--muted)} -.filter-bar{border-color:var(--border);background:var(--surface-soft)}.filter-bar .button{background:var(--surface);border:1px solid var(--border-strong);color:var(--text)}.customer-match{color:var(--blue-text)!important}.diagnostic-grid article{background:var(--surface-soft);border-color:var(--border)}.diagnostic-grid span,.diagnostic-grid small{color:var(--muted)}.export-links .button{background:var(--surface-soft);border:1px solid var(--border-strong);color:var(--text)} +.filter-bar{border-color:var(--border);background:var(--surface-soft)}.filter-bar .button{background:var(--surface);border:1px solid var(--border-strong);color:var(--text)}.customer-match{color:var(--blue-text)!important}.diagnostic-grid article,.automatic-backups{background:var(--surface-soft);border-color:var(--border)}.diagnostic-grid span,.diagnostic-grid small,.automatic-backups p,.backup-schedule-status{color:var(--muted)}.export-links .button{background:var(--surface-soft);border:1px solid var(--border-strong);color:var(--text)} .note-category{background:var(--blue-soft);color:var(--blue-text)}.backup-warning{background:var(--warning-soft);border-color:color-mix(in srgb,var(--warning) 40%,transparent);color:var(--warning)} .auth-screen{background:linear-gradient(145deg,color-mix(in srgb,var(--blue) 10%,var(--page)),var(--page))}.auth-card{box-shadow:0 22px 60px var(--shadow)} dialog{max-height:min(90dvh,900px);overflow:auto}dialog::backdrop{background:#050914aa}.close{color:var(--muted)} diff --git a/server.mjs b/server.mjs index 6900235..c7880fa 100644 --- a/server.mjs +++ b/server.mjs @@ -5,7 +5,7 @@ import { extname, join, normalize } from "node:path"; import { backup, DatabaseSync } from "node:sqlite"; import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto"; -const APP_VERSION="3.3.0",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data"; +const APP_VERSION="3.3.1",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data"; const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public"); const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map(); mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true}); @@ -43,7 +43,7 @@ function initializeDatabase(){ 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 setting=db.prepare("INSERT OR IGNORE INTO app_settings (key,value) VALUES (?,?)");for(const [key,value] of [["schema_version",String(SCHEMA_VERSION)],["backup_enabled","0"],["backup_hour","2"],["backup_retention","14"],["last_scheduled_backup",""]])setting.run(key,value); + const setting=db.prepare("INSERT OR IGNORE INTO app_settings (key,value) VALUES (?,?)");for(const [key,value] of [["schema_version",String(SCHEMA_VERSION)],["backup_enabled","0"],["backup_frequency","daily"],["backup_weekday","0"],["backup_hour","2"],["backup_retention","14"],["last_scheduled_backup",""]])setting.run(key,value); db.prepare("UPDATE app_settings SET value=? WHERE key='schema_version'").run(String(SCHEMA_VERSION)); } const hashPassword=password=>{const salt=randomBytes(16).toString("hex");return `scrypt$${salt}$${scryptSync(password,salt,64).toString("hex")}`}; @@ -77,9 +77,10 @@ const backupName=(prefix="vboxstock")=>`${prefix}-${new Date().toISOString().rep async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name} const setting=key=>db.prepare("SELECT value FROM app_settings WHERE key=?").get(key)?.value||""; function saveSetting(key,value){db.prepare("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=CURRENT_TIMESTAMP").run(key,String(value))} +function nextAutomaticBackup(){if(setting("backup_enabled")!=="1")return null;const now=new Date(),next=new Date(now),hour=Math.max(0,Math.min(23,Number(setting("backup_hour"))||0)),frequency=setting("backup_frequency")==="weekly"?"weekly":"daily",weekday=Math.max(0,Math.min(6,Number(setting("backup_weekday"))||0));next.setHours(hour,0,0,0);if(frequency==="daily"){if(next<=now)next.setDate(next.getDate()+1)}else{let days=(weekday-next.getDay()+7)%7;if(days===0&&next<=now)days=7;next.setDate(next.getDate()+days)}return next.toISOString()} async function backupFiles(){const files=await readdir(backupDir,{withFileTypes:true}),result=[];for(const file of files)if(file.isFile()&&file.name.endsWith(".db")){const info=await stat(join(backupDir,file.name));result.push({name:file.name,size:info.size,createdAt:info.mtime.toISOString()})}return result.sort((a,b)=>b.createdAt.localeCompare(a.createdAt))} -async function pruneScheduledBackups(){const keep=Math.max(1,Math.min(365,Number(setting("backup_retention"))||14)),files=(await backupFiles()).filter(x=>x.name.startsWith("scheduled-"));for(const file of files.slice(keep))await unlink(join(backupDir,file.name)).catch(()=>{})} -async function runScheduledBackup(){if(setting("backup_enabled")!=="1")return;const now=new Date(),day=now.toISOString().slice(0,10),hour=Math.max(0,Math.min(23,Number(setting("backup_hour"))||0));if(now.getHours()x.name.startsWith("automatic-")||x.name.startsWith("scheduled-"));for(const file of files.slice(keep))await unlink(join(backupDir,file.name)).catch(()=>{})} +async function runScheduledBackup(){if(setting("backup_enabled")!=="1")return;const now=new Date(),day=now.toLocaleDateString("en-CA"),hour=Math.max(0,Math.min(23,Number(setting("backup_hour"))||0)),frequency=setting("backup_frequency")==="weekly"?"weekly":"daily",weekday=Math.max(0,Math.min(6,Number(setting("backup_weekday"))||0));if(now.getHours()runScheduledBackup().catch(error=>console.error("Scheduled backup failed:",error.message)),15*60*1000).unref();setTimeout(()=>runScheduledBackup().catch(error=>console.error("Scheduled backup failed:",error.message)),1000).unref(); function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)} const csvCell=value=>`"${String(value??"").replaceAll('"','""')}"`,csv=(headers,rows)=>[headers.map(csvCell).join(","),...rows.map(row=>row.map(csvCell).join(","))].join("\r\n")+"\r\n"; @@ -110,8 +111,8 @@ async function authApi(req,res,url,session){ async function adminApi(req,res,url,session){ const user=session.user; - if(url.pathname==="/api/admin/diagnostics"&&req.method==="GET"){const databaseInfo=await stat(databasePath),disk=await statfs(dataDir),backups=await backupFiles();let writable=true;try{const probe=join(dataDir,`.write-test-${crypto.randomUUID()}`);await writeFile(probe,"ok");await unlink(probe)}catch{writable=false}return json(res,200,{appVersion:APP_VERSION,schemaVersion:Number(setting("schema_version"))||SCHEMA_VERSION,nodeVersion:process.version,timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone,dataDirectory:dataDir,dataWritable:writable,databaseSize:databaseInfo.size,backupCount:backups.length,lastBackupAt:backups[0]?.createdAt||null,diskFree:disk.bavail*disk.bsize,diskTotal:disk.blocks*disk.bsize,backupSettings:{enabled:setting("backup_enabled")==="1",hour:Number(setting("backup_hour"))||0,retention:Number(setting("backup_retention"))||14,lastScheduledBackup:setting("last_scheduled_backup")||null}})} - if(url.pathname==="/api/admin/backup-settings"&&req.method==="PATCH"){const v=await body(req),enabled=Boolean(v.enabled),hour=Number(v.hour),retention=Number(v.retention);if(!Number.isInteger(hour)||hour<0||hour>23||!Number.isInteger(retention)||retention<1||retention>365)throw new Error("Backup hour must be 0–23 and retention must be 1–365.");saveSetting("backup_enabled",enabled?"1":"0");saveSetting("backup_hour",hour);saveSetting("backup_retention",retention);await pruneScheduledBackups();audit(req,user,"backup_schedule_updated","",`${enabled?"enabled":"disabled"}; ${hour}:00; retain ${retention}`);return json(res,200,{ok:true})} + if(url.pathname==="/api/admin/diagnostics"&&req.method==="GET"){const databaseInfo=await stat(databasePath),disk=await statfs(dataDir),backups=await backupFiles();let writable=true;try{const probe=join(dataDir,`.write-test-${crypto.randomUUID()}`);await writeFile(probe,"ok");await unlink(probe)}catch{writable=false}return json(res,200,{appVersion:APP_VERSION,schemaVersion:Number(setting("schema_version"))||SCHEMA_VERSION,nodeVersion:process.version,timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone,dataDirectory:dataDir,dataWritable:writable,databaseSize:databaseInfo.size,backupCount:backups.length,lastBackupAt:backups[0]?.createdAt||null,diskFree:disk.bavail*disk.bsize,diskTotal:disk.blocks*disk.bsize,backupSettings:{enabled:setting("backup_enabled")==="1",frequency:setting("backup_frequency")==="weekly"?"weekly":"daily",weekday:Number(setting("backup_weekday"))||0,hour:Number(setting("backup_hour"))||0,retention:Number(setting("backup_retention"))||14,lastScheduledBackup:setting("last_scheduled_backup")||null,nextScheduledBackup:nextAutomaticBackup()}})} + if(url.pathname==="/api/admin/backup-settings"&&req.method==="PATCH"){const v=await body(req),enabled=Boolean(v.enabled),frequency=String(v.frequency||"daily"),weekday=Number(v.weekday),hour=Number(v.hour),retention=Number(v.retention);if(!new Set(["daily","weekly"]).has(frequency)||!Number.isInteger(weekday)||weekday<0||weekday>6||!Number.isInteger(hour)||hour<0||hour>23||!Number.isInteger(retention)||retention<1||retention>365)throw new Error("Choose a valid frequency, weekday, backup hour, and retention period.");saveSetting("backup_enabled",enabled?"1":"0");saveSetting("backup_frequency",frequency);saveSetting("backup_weekday",weekday);saveSetting("backup_hour",hour);saveSetting("backup_retention",retention);await pruneScheduledBackups();audit(req,user,"backup_schedule_updated","",`${enabled?"enabled":"disabled"}; ${frequency}${frequency==="weekly"?` day ${weekday}`:""}; ${hour}:00; retain ${retention}`);return json(res,200,{ok:true,nextScheduledBackup:nextAutomaticBackup()})} 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\/([^/]+)$/); diff --git a/test/server.test.mjs b/test/server.test.mjs index a617ccb..43e80c0 100644 --- a/test/server.test.mjs +++ b/test/server.test.mjs @@ -52,9 +52,9 @@ test("authentication, roles, inventory, sale, and restock", async t => { response=await request("/api/customers",{headers:{cookie:adminCookie}});const customerDirectory=await response.json();assert.equal(customerDirectory.length,1);assert.equal(customerDirectory[0].phone,"(215) 555-0100"); response=await request("/api/export/sales",{headers:{cookie:viewerCookie}});assert.equal(response.status,200);assert.match(response.headers.get("content-type"),/text\/csv/);assert.match(await response.text(),/PP-456/); response=await request("/api/export/audit",{headers:{cookie:viewerCookie}});assert.equal(response.status,403,"read-only users cannot export the security audit"); - response=await request("/api/admin/diagnostics",{headers:{cookie:adminCookie}});const diagnostics=await response.json();assert.equal(diagnostics.appVersion,"3.3.0");assert.equal(diagnostics.dataWritable,true);assert.equal(diagnostics.backupSettings.enabled,false); - response=await request("/api/admin/backup-settings",{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({enabled:true,hour:3,retention:7})});assert.equal(response.status,200); - response=await request("/api/admin/diagnostics",{headers:{cookie:adminCookie}});assert.deepEqual((await response.json()).backupSettings,{enabled:true,hour:3,retention:7,lastScheduledBackup:null}); + response=await request("/api/admin/diagnostics",{headers:{cookie:adminCookie}});const diagnostics=await response.json();assert.equal(diagnostics.appVersion,"3.3.1");assert.equal(diagnostics.dataWritable,true);assert.equal(diagnostics.backupSettings.enabled,false);assert.equal(diagnostics.backupSettings.frequency,"daily");assert.equal(diagnostics.backupSettings.nextScheduledBackup,null); + response=await request("/api/admin/backup-settings",{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({enabled:true,frequency:"weekly",weekday:1,hour:3,retention:7})});assert.equal(response.status,200); + response=await request("/api/admin/diagnostics",{headers:{cookie:adminCookie}});const scheduled=(await response.json()).backupSettings;assert.equal(scheduled.enabled,true);assert.equal(scheduled.frequency,"weekly");assert.equal(scheduled.weekday,1);assert.equal(scheduled.hour,3);assert.equal(scheduled.retention,7);assert.equal(scheduled.lastScheduledBackup,null);assert.ok(scheduled.nextScheduledBackup); response=await request(`/api/admin/warranties/${customWarranty.id}`,{method:"DELETE",headers:{cookie:adminCookie}});assert.equal(response.status,400,"a used warranty cannot be deleted"); response=await request(`/api/admin/warranties/${customWarranty.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"Changed",durationValue:10,durationUnit:"days"})});assert.equal(response.status,400,"a used warranty cannot be edited"); response=await request(`/api/admin/warranties/${customWarranty.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({active:false})});assert.equal(response.status,200,"a used warranty can be archived");