← back to Dw Photo Capture
harden (TK-11962 review #7/#8/#9): server.on('error') + unconditional create gate + persist in-progress capture
c87b661fdb705da914e5dfba61eb41ea7c44487e · 2026-09-21 06:31:54 -0700 · Steve Abrams
Steve-approved punch-list from the e2e chief-of-staff review (local only; prod deploy stays gated).
#7 (finish): add server.on('error') to the http + https listeners — an EADDRINUSE from a
second accidental launch now logs + exit(1) for clean supervisor backoff instead of being
swallowed by the uncaughtException handler into a limping non-listening process. (The
process-level unhandledRejection/uncaughtException net + dataUrl string-guards already
landed in 9a8a272.)
#9: createNewItem's two-photo/identity gate is now enforced UNCONDITIONALLY. The old
`if (p.require_two)` let a raw POST that simply omitted the flag opt OUT of its own
validation and create a Shopify DRAFT + FileMaker master with zero photos and an arbitrary
mfr#. The UI always sends require_two and there is no legitimate no-photo create path, so
the gate is now the secure default (verified: sole caller is /api/create-item).
#8: persist the in-progress capture (_frontPhoto/_backPhoto/_media/typed fields) to
sessionStorage on every shot + field edit, and restore it on load — so an iOS Safari
backgrounding-reload mid-capture no longer silently discards an operator's two photos.
Cleared on commit and on deliberate "next item".
Validated: node --check ✓, eslint adds zero new problems, auth logic unchanged, pm2
dwphoto-local untouched. NOT deployed — external publish to photo.designerwallcoverings.com
remains Steve-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FznUHpstTf6iEYfiYYwLE
Files touched
M public/index.htmlM server.js
Diff
commit c87b661fdb705da914e5dfba61eb41ea7c44487e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Sep 21 06:31:54 2026 -0700
harden (TK-11962 review #7/#8/#9): server.on('error') + unconditional create gate + persist in-progress capture
Steve-approved punch-list from the e2e chief-of-staff review (local only; prod deploy stays gated).
#7 (finish): add server.on('error') to the http + https listeners — an EADDRINUSE from a
second accidental launch now logs + exit(1) for clean supervisor backoff instead of being
swallowed by the uncaughtException handler into a limping non-listening process. (The
process-level unhandledRejection/uncaughtException net + dataUrl string-guards already
landed in 9a8a272.)
#9: createNewItem's two-photo/identity gate is now enforced UNCONDITIONALLY. The old
`if (p.require_two)` let a raw POST that simply omitted the flag opt OUT of its own
validation and create a Shopify DRAFT + FileMaker master with zero photos and an arbitrary
mfr#. The UI always sends require_two and there is no legitimate no-photo create path, so
the gate is now the secure default (verified: sole caller is /api/create-item).
#8: persist the in-progress capture (_frontPhoto/_backPhoto/_media/typed fields) to
sessionStorage on every shot + field edit, and restore it on load — so an iOS Safari
backgrounding-reload mid-capture no longer silently discards an operator's two photos.
Cleared on commit and on deliberate "next item".
Validated: node --check ✓, eslint adds zero new problems, auth logic unchanged, pm2
dwphoto-local untouched. NOT deployed — external publish to photo.designerwallcoverings.com
remains Steve-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FznUHpstTf6iEYfiYYwLE
---
public/index.html | 37 ++++++++++++++++++++++++++++++++++---
server.js | 17 +++++++++++++++--
2 files changed, 49 insertions(+), 5 deletions(-)
diff --git a/public/index.html b/public/index.html
index 1a0cd4a..9318423 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1939,11 +1939,35 @@ function renderMedia(){ const c=mediaCounts();
: `<div class="media-cell" style="background-image:url('${m.dataUrl}')"><button class="rm" data-rm="${i}">✕</button></div>`).join('');
$('#addMedia').querySelectorAll('.rm').forEach(b=>b.addEventListener('click',()=>{ _media.splice(+b.dataset.rm,1); renderMedia(); })); }
function addResetMedia(){ _media=[]; _addPhoto=null; _frontPhoto=null; _backPhoto=null; _idSource=null; _addExtracted={}; _extracted=false; _updatePid=null; _updateSku=null; _updateNeedsConfirm=false; $('#addSpecs').innerHTML=''; if(typeof clearCandidates==='function')clearCandidates(); renderMedia(); renderShots(); }
+// ── In-progress capture persistence (TK-11962) ───────────────────────────────
+// _frontPhoto/_backPhoto/_media/fields lived only in memory, so an iOS Safari
+// backgrounding-reload mid-capture silently discarded both shots. Snapshot to
+// sessionStorage (per-tab, survives reload, gone on tab close) so a reload
+// restores the in-progress item instead of losing an operator's two photos.
+const _CAP_KEY='dwcap.inprogress';
+const _CAP_FIELDS=['addMfr','addName','addColor','addPrice','addWidth','addRepeat','addContent','addHowSold','addNotes'];
+function clearCaptureState(){ try{ sessionStorage.removeItem(_CAP_KEY); }catch(e){} }
+function hasSavedCapture(){ try{ const s=sessionStorage.getItem(_CAP_KEY); if(!s) return false; const d=JSON.parse(s); return !!(d && (d.front||d.back||(d.media&&d.media.length))); }catch(e){ return false; } }
+function saveCaptureState(){ try{
+ if(typeof _addMode!=='undefined' && _addMode==='update') return; // update-existing flow is not a new-item capture
+ if(!_frontPhoto && !_backPhoto && !(_media&&_media.length)){ clearCaptureState(); return; }
+ const fields={}; _CAP_FIELDS.forEach(id=>{ const el=$('#'+id); if(el) fields[id]=el.value; });
+ sessionStorage.setItem(_CAP_KEY, JSON.stringify({ v:1, ts:Date.now(), front:_frontPhoto, back:_backPhoto, media:_media, addPhoto:_addPhoto, idSource:_idSource, extracted:_addExtracted, fields }));
+}catch(e){} }
+function restoreCaptureState(){ try{
+ const s=sessionStorage.getItem(_CAP_KEY); if(!s) return false; const d=JSON.parse(s); if(!d) return false;
+ _frontPhoto=d.front||null; _backPhoto=d.back||null; _media=Array.isArray(d.media)?d.media:[];
+ _addPhoto=d.addPhoto||_frontPhoto||_backPhoto||null; _idSource=d.idSource||null; _addExtracted=d.extracted||{};
+ if(d.fields) Object.keys(d.fields).forEach(id=>{ const el=$('#'+id); if(el && d.fields[id]!=null) el.value=d.fields[id]; });
+ try{ renderMedia(); }catch(e){} try{ renderShots(); }catch(e){}
+ const n=(_frontPhoto?1:0)+(_backPhoto?1:0); toast('↩ Restored your in-progress capture'+(n?(' ('+n+' photo'+(n>1?'s':'')+')'):''));
+ return true;
+}catch(e){ return false; } }
function fileToDataUrl(f){ return new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); }); }
async function addMediaAdd(type,file){ if(!file)return; const c=mediaCounts();
if(type==='photo'){ if(c.p>=MAX_PHOTOS){ toast('Max '+MAX_PHOTOS+' photos'); return; }
const url=await downscale(file,1400); if(!url){ toast('Could not read that photo — try again or use a JPEG'); return; }
- _media.push({type:'photo',dataUrl:url}); if(!_addPhoto)_addPhoto=url; renderMedia();
+ _media.push({type:'photo',dataUrl:url}); if(!_addPhoto)_addPhoto=url; renderMedia(); saveCaptureState();
if(!_extracted && _addMode==='update'){ _extracted=true; addExtract(); } // UPDATE mode: first extra photo = label scan (ADD mode uses the 2-photo autoExtract)
} else { if(c.v>=MAX_VIDEOS){ toast('Max '+MAX_VIDEOS+' videos'); return; }
if(file.size>150*1024*1024){ toast('Video too big (>150MB)'); return; }
@@ -1963,6 +1987,7 @@ async function shotAdd(side,file){ if(!file)return;
renderShots();
if(_addMode==='update'){ if(!_extracted){ _extracted=true; await addExtract(url); } return; } // UPDATE mode keeps its label-resolve
if(_frontPhoto && _backPhoto) await autoExtract(); // ADD mode: determine only after BOTH
+ saveCaptureState(); // TK-11962: persist so an iOS reload mid-capture keeps the shot
}
// Ordered photo list the backend receives: FRONT (pos1) then BACK (pos2) then any extras.
function addPhotosList(){ return [_frontPhoto,_backPhoto].filter(Boolean).concat(_media.filter(m=>m.type==='photo').map(m=>m.dataUrl)); }
@@ -2243,6 +2268,7 @@ async function landShot(side,url){
renderShots();
if(_addMode==='update'){ if(!_extracted){ _extracted=true; await addExtract(url); } return; }
if(_frontPhoto && _backPhoto) await autoExtract();
+ saveCaptureState(); // TK-11962: persist so an iOS reload mid-capture keeps the shot
}
async function tsShutter(){
if(_tsBusy||!_tsLive) return; _tsBusy=true; $('#tsShutterBtn').disabled=true;
@@ -2303,7 +2329,7 @@ async function detectAndShowColor(){ if(!_frontPhoto)return; const col=await det
// ── CONTINUE-ABLE batch: after a create, clear the per-item state but KEEP vendor + calibration + WB ──
let _sessCount=0;
function updateSessCount(){ const c=$('#sessCount'); if(!c)return; if(_sessCount>0){ c.hidden=false; c.textContent=_sessCount+' this session'; } else { c.hidden=true; } }
-function nextItem(){ addResetMedia();
+function nextItem(){ clearCaptureState(); addResetMedia();
['addMfr','addName','addColor','addPrice','addWidth','addRepeat','addContent','addHowSold','addNotes'].forEach(id=>{ const el=$('#'+id); if(el){ el.value=''; el.classList.remove('need'); } });
const cr=$('#colorRead'); if(cr){ cr.hidden=true; cr.classList.remove('show'); cr.innerHTML=''; }
clearCandidates(); $('#addNote').textContent=''; $('#addResult').innerHTML='';
@@ -2346,7 +2372,7 @@ function restoreStickyVendor(){ const v=LS('vendor'); if(v && [...$('#stickyVend
function onStickyPick(){ const s=$('#stickyVendor'); setStickyVendor(s.value, true); }
// Modal picker changed (rare — a one-off override) → also update the sticky selection.
function onVendorPick(){ const val=$('#addVendor').value; setStickyVendor(val, true); }
-function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _addSession++; addResetMedia();
+function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _addSession++; if(!opts.restore) clearCaptureState(); addResetMedia();
['addMfr','addVid','addName','addColor','addPrice','addWidth','addRepeat','addContent','addHowSold','addNotes'].forEach(id=>{ const el=$('#'+id); if(el)el.value=''; }); $('#addNote').textContent=''; $('#addResult').innerHTML='';
const cr=$('#colorRead'); if(cr){ cr.hidden=true; cr.classList.remove('show'); cr.innerHTML=''; } _sessCount=0; updateSessCount();
$('#addTitleTxt').textContent = _addMode==='update' ? 'Update SKU on Shopify' : 'Add new item';
@@ -2363,6 +2389,7 @@ function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _add
});
// onload → go straight to camera. ADD → the live two-shot view (front); UPDATE → the label snap.
if(opts.camera){ if(_addMode==='update') setTimeout(()=>$('#addPhotoInput').click(),250); else setTimeout(()=>openTwoShotCam('front'),250); }
+ if(opts.restore){ restoreCaptureState(); } // TK-11962: repopulate an in-progress capture after a reload
}
function addVideosList(){ return _media.filter(m=>m.type==='video'); }
function addPayload(commit){ const v=id=>{ const el=$('#'+id); return el?el.value.trim():''; };
@@ -2411,6 +2438,7 @@ async function addCommit(){ const g=twoPhotoGate(); if(!g.ok){ $('#addNote').tex
const b=$('#addCommit'); b.disabled=true; $('#addNote').textContent='Creating draft…';
const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(addPayload(true))})).json();
if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); b.disabled=false; return; }
+ clearCaptureState(); // TK-11962: item created — drop the in-progress capture snapshot
const hadVideos=addVideosList().length; let vmsg='', vup={ok:0,failed:0};
if(hadVideos && r.product_id){ vup=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:''); }
_sessCount++; updateSessCount();
@@ -2720,6 +2748,9 @@ $('#simInput').addEventListener('change',async e=>{
});
applyCamCapture(); syncCamBtn(); // apply saved camera choice to capture inputs + header toggle
load(); loadNew(); loadFav(); loadRecent(); loadVendors(); // loadVendors: populate + auto-restore the sticky camera vendor bar on boot. NOTE: loadTwil() (36MB/88k) is lazy — fires on 'skip'/TWIL view, not page-load (iOS memory)
+// TK-11962: persist typed field edits, and restore an in-progress capture after an iOS reload.
+try{ _CAP_FIELDS.forEach(id=>{ const el=$('#'+id); if(el) el.addEventListener('input',()=>{ if(_frontPhoto||_backPhoto||(_media&&_media.length)) saveCaptureState(); }); }); }catch(e){}
+try{ if(hasSavedCapture()) openAddModal(null,{restore:true}); }catch(e){}
setInterval(()=>{ if(filter!=='any'&&filter!=='shop'&&filter!=='fav'&&filter!=='recent'&&filter!=='similar') load(); }, 60000);
</script>
</body>
diff --git a/server.js b/server.js
index a20477f..b4ca154 100644
--- a/server.js
+++ b/server.js
@@ -2329,7 +2329,9 @@ const server = http.createServer(appHandler);
// HTTPS (self-signed) on PORT+1 so the live camera scanner (getUserMedia needs a secure context) works on the LAN.
try {
const tls = { key: fs.readFileSync(path.join(ROOT, 'certs/key.pem')), cert: fs.readFileSync(path.join(ROOT, 'certs/cert.pem')) };
- https.createServer(tls, appHandler).listen(Number(PORT) + 1, '0.0.0.0', () => console.log(`HTTPS (live-scan) on https://0.0.0.0:${Number(PORT) + 1}`));
+ const httpsSrv = https.createServer(tls, appHandler);
+ httpsSrv.on('error', (e) => console.log('[https.on error]', (e && e.code) || (e && e.message) || e)); // secondary LAN listener — log, don't crash
+ httpsSrv.listen(Number(PORT) + 1, '0.0.0.0', () => console.log(`HTTPS (live-scan) on https://0.0.0.0:${Number(PORT) + 1}`));
} catch (e) { console.log('HTTPS off (no cert):', e.message); }
// ── Sheet GRS catalog (every GRS item on the TWIL spreadsheet, incl. not-yet-created) ──
@@ -2610,7 +2612,11 @@ async function createNewItem(p, b64, dryRun) {
// FRONT photo, and its mfr# identity must come from front-OCR, back-OCR, or manual entry (mfr, above).
// Back is optional only when the front already yielded the code — the UI enforces which; here we just
// guarantee a front image + a real mfr# so an item can never be created with no product photo/identity.
- if (p.require_two) {
+ // Enforced UNCONDITIONALLY server-side (TK-11962): the old `if (p.require_two)` let a raw
+ // POST that simply omitted the flag opt OUT of its own validation and create a Shopify DRAFT
+ // + FileMaker master with zero photos and an arbitrary mfr#. The UI always sends require_two,
+ // and there is no legitimate no-photo create path, so the gate is now the secure default.
+ {
const nPhotos = Array.isArray(p._photos64) ? p._photos64.length : (b64 ? 1 : 0);
if (nPhotos < 1) return { ok: false, err: 'front (pattern) photo required' };
if (!p.back_present && !mfr) return { ok: false, err: 'no back photo and no manual mfr# — capture the back label or enter the mfr#/SKU' };
@@ -3150,6 +3156,13 @@ function visualSearch(b64, k) {
});
}
+// Listen-time failure (e.g. EADDRINUSE from a second accidental launch): log + exit(1) so the
+// supervisor (pm2/launchd) applies its own backoff, instead of the uncaughtException handler
+// swallowing it into a limping process that never actually bound the port. (TK-11962)
+server.on('error', (e) => {
+ if (e && e.code === 'EADDRINUSE') { console.error(`[server.on error] port ${PORT} already in use — another instance is running; exiting for supervisor backoff`); process.exit(1); }
+ console.error('[server.on error]', (e && e.stack) || e);
+});
server.listen(PORT, '0.0.0.0', () => {
console.log(`DW Photo Capture on http://0.0.0.0:${PORT} (Shopify push: ${TOKEN ? 'ON' : 'OFF — no token'}, sheet GRS: ${SHEET.length})`);
rebuildIndex(); // sheet-only items searchable immediately
← 3af4c92 auto-data-snapshot: 2026-09-21T04:28:28 (1 data files) — dat
·
back to Dw Photo Capture
·
auto-data-snapshot: 2026-09-21T06:39:23 (1 data files) — dat fc87163 →