diff --git a/projects/dataforth-dos/testdatadb-fix/public/index.redesign.html b/projects/dataforth-dos/testdatadb-fix/public/index.redesign.html index 8bb3c490..4568bea0 100644 --- a/projects/dataforth-dos/testdatadb-fix/public/index.redesign.html +++ b/projects/dataforth-dos/testdatadb-fix/public/index.redesign.html @@ -204,7 +204,7 @@
0 selected - +
@@ -244,7 +244,7 @@ const API=''; const $=id=>document.getElementById(id); const state={q:'',serial:'',model:'',result:'',station:'',logtype:'',from:'',to:'',size:50,page:0,total:0, - selected:null,rows:[],sort:'',dir:'desc',checks:new Set(),force:''}; + selected:null,rows:[],sort:'',dir:'desc',checks:new Set(),force:'',webStatus:''}; let timer=null, certTimer=null; const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); const fmtDate=d=>d?String(d).slice(0,10):''; @@ -269,7 +269,8 @@ $('mode').onclick=()=>{ const seq=['auto','serial','model','text']; state.force= function params(forExport){ const p=new URLSearchParams(); for(const k of ['q','serial','model','result','station','logtype','from','to']) if(state[k]) p.set(k,state[k]); - if(state.sort){ p.set('sort',state.sort); p.set('dir',state.dir); } // needs API; harmless if ignored + if(state.sort){ p.set('sort',state.sort); p.set('dir',state.dir); } // honored by /api/search (whitelisted) + if(state.webStatus) p.set('web_status',state.webStatus); // on=published, off=not yet published if(!forExport){ p.set('limit',state.size); p.set('offset',state.page*state.size); } return p; } @@ -332,7 +333,7 @@ function select(id,auto){ TXT HTML - `; + `; $('insp').classList.add('open'); // lazy-load the certificate so fast arrow/typing stays snappy $('viewer').innerHTML='
'; @@ -372,6 +373,31 @@ $('copySel').onclick=()=>{ const sns=state.rows.filter(r=>state.checks.has(Strin navigator.clipboard&&navigator.clipboard.writeText(sns); $('copySel').textContent='Copied ✓'; setTimeout(()=>$('copySel').textContent='Copy serials',1200); }; $('selclear').onclick=()=>{ state.checks.clear(); [...$('rows').querySelectorAll('[data-ck]')].forEach(c=>c.checked=false); $('ckAll').checked=false; updateSel(); }; +/* ---------- publish to public website (POST /api/upload, idempotent) ---------- */ +async function doUpload(payload){ + const r=await fetch(API+'/api/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); + const d=await r.json().catch(()=>({})); if(!r.ok) throw new Error(d.error||('HTTP '+r.status)); return d; +} +async function pushWeb(id,btn){ + const r=state.rows.find(x=>x.id==id); const sn=r?r.serial_number:id; + if(!confirm('Publish '+sn+' to the public Dataforth website now?')) return; + const t=btn.textContent; btn.disabled=true; btn.textContent='Publishing…'; + try{ const d=await doUpload({ids:[+id]}); + btn.textContent=d.errors?('✕ '+d.errors+' err'):(d.skipped&&!(d.created+d.updated+d.unchanged)?'skipped':'Published ✓'); + setTimeout(search,500); // refresh WEB status from the DB + }catch(e){ btn.textContent='✕ '+e.message.slice(0,16); btn.disabled=false; } +} +async function pushSelected(){ + const ids=[...state.checks].map(Number); if(!ids.length) return; + if(!confirm('Publish '+ids.length+' selected serial(s) to the public Dataforth website now?')) return; + const b=$('repushSel'),t=b.textContent; b.disabled=true; b.textContent='Publishing…'; + try{ const d=await doUpload({ids}); + b.textContent='✓ '+((d.created||0)+(d.updated||0))+' pushed'+(d.skipped?(' · '+d.skipped+' skip'):''); + setTimeout(()=>{b.textContent=t;b.disabled=false;search();},1400); + }catch(e){ b.textContent='✕ failed'; b.disabled=false; alert('Push failed: '+e.message); } +} +$('repushSel').onclick=pushSelected; + /* ---------- sort ---------- */ function updateSortHeads(){ [...document.querySelectorAll('thead th.s')].forEach(th=>{ const on=th.dataset.s===state.sort; th.querySelector('.arr')?.remove(); @@ -394,12 +420,12 @@ $('pageSize').onchange=e=>{state.size=+e.target.value;state.page=0;search();}; $('prev').onclick=()=>{if(state.page>0){state.page--;search();$('twrap').scrollTop=0;}}; $('next').onclick=()=>{state.page++;search();$('twrap').scrollTop=0;}; $('reset').onclick=()=>{ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]=''); - state.sort='';state.checks.clear();updateSel(); $('omni').value='';$('mode').textContent=state.force||'auto'; + state.sort='';state.webStatus='';state.checks.clear();updateSel(); $('omni').value='';$('mode').textContent=state.force||'auto'; $('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; state.page=0; search(); }; $('menu').onclick=()=>document.body.classList.toggle('rail-open'); /* ---------- presets ---------- */ -function clearAll(){ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]=''); +function clearAll(){ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]=''); state.webStatus='';state.sort=''; $('omni').value='';$('mode').textContent=state.force||'auto';$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; } function applyPreset(fn){ clearAll(); fn(); state.page=0; state.checks.clear(); updateSel(); document.body.classList.remove('rail-open'); search(); } const PRESETS=[ @@ -407,11 +433,11 @@ const PRESETS=[ {ic:'✕',label:'Failures',fn:()=>{state.result='FAIL';}}, {ic:'•',label:'Today',fn:()=>{const t=isoD(new Date());state.from=t;state.to=t;}}, {ic:'7',label:'Last 7 days',fn:()=>{const d=new Date();d.setDate(d.getDate()-7);state.from=isoD(d);}}, + {ic:'▴',label:'Latest uploads',fn:()=>{state.webStatus='on';state.sort='api_uploaded_at';state.dir='desc';}}, + {ic:'○',label:'Not yet published',fn:()=>{state.webStatus='off';}}, ]; const SOON=[ - {label:'Latest upload batch',why:'needs sort=uploaded in /api/search'}, - {label:'Retested units',why:'needs a retest flag in the pipeline'}, - {label:'Not yet published',why:'needs a published filter in /api/search'}, + {label:'Retested units',why:'needs a retest flag in the pipeline (next)'}, ]; function renderPresets(){ $('presets').innerHTML=''; diff --git a/projects/dataforth-dos/testdatadb-fix/routes/api.js b/projects/dataforth-dos/testdatadb-fix/routes/api.js index ece50d7d..a5f68ef6 100644 --- a/projects/dataforth-dos/testdatadb-fix/routes/api.js +++ b/projects/dataforth-dos/testdatadb-fix/routes/api.js @@ -1,27 +1,17 @@ /** * API Routes for Test Data Database * - * Fixed version - uses a single persistent database connection instead of - * opening and closing on every request. WAL journal mode enabled for - * concurrent read support. Limit parameter capped at 1000. + * PostgreSQL version - uses pg.Pool via database/db.js. + * All route handlers are async. FTS uses tsvector/plainto_tsquery. */ const express = require('express'); const path = require('path'); -const Database = require('better-sqlite3'); +const db = require('../database/db'); const { generateDatasheet } = require('../templates/datasheet'); const router = express.Router(); -// --------------------------------------------------------------------------- -// Singleton database connection - opened once at module load -// --------------------------------------------------------------------------- -const DB_PATH = path.join(__dirname, '..', 'database', 'testdata.db'); - -const db = new Database(DB_PATH, { readonly: false }); -db.pragma('journal_mode = WAL'); -db.pragma('busy_timeout = 5000'); - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -42,107 +32,88 @@ function clampOffset(value) { // --------------------------------------------------------------------------- // GET /api/search // Search test records -// Query params: serial, model, from, to, result, q, station, logtype, limit, offset +// Query params: serial, model, from, to, result, q, station, logtype, web_status, limit, offset // --------------------------------------------------------------------------- -router.get('/search', (req, res) => { +router.get('/search', async (req, res) => { try { - const { serial, model, from, to, result, q, station, logtype } = req.query; + const { serial, model, from, to, result, q, station, logtype, workorder, web_status } = req.query; const limit = clampLimit(req.query.limit || 100); const offset = clampOffset(req.query.offset || 0); - let sql = 'SELECT * FROM test_records WHERE 1=1'; + const conditions = []; const params = []; + let paramIdx = 0; + + const addParam = (val) => { + paramIdx++; + params.push(val); + return '$' + paramIdx; + }; + + if (q) { + // Full-text search using tsvector + conditions.push(`search_vector @@ plainto_tsquery('english', ${addParam(q)})`); + } if (serial) { - sql += ' AND serial_number LIKE ?'; - params.push(serial.includes('%') ? serial : `%${serial}%`); + const val = serial.includes('%') ? serial : `%${serial}%`; + conditions.push(`serial_number LIKE ${addParam(val)}`); + } + + if (workorder) { + conditions.push(`work_order = ${addParam(workorder)}`); } if (model) { - sql += ' AND model_number LIKE ?'; - params.push(model.includes('%') ? model : `%${model}%`); + const val = model.includes('%') ? model : `%${model}%`; + conditions.push(`model_number LIKE ${addParam(val)}`); } if (from) { - sql += ' AND test_date >= ?'; - params.push(from); + conditions.push(`test_date >= ${addParam(from)}`); } if (to) { - sql += ' AND test_date <= ?'; - params.push(to); + conditions.push(`test_date <= ${addParam(to)}`); } if (result) { - sql += ' AND overall_result = ?'; - params.push(result.toUpperCase()); + conditions.push(`overall_result = ${addParam(result.toUpperCase())}`); } if (station) { - sql += ' AND test_station = ?'; - params.push(station); + conditions.push(`test_station = ${addParam(station)}`); } if (logtype) { - sql += ' AND log_type = ?'; - params.push(logtype); + conditions.push(`log_type = ${addParam(logtype)}`); } - if (q) { - // Full-text search - rebuild query with FTS - sql = `SELECT test_records.* FROM test_records - JOIN test_records_fts ON test_records.id = test_records_fts.rowid - WHERE test_records_fts MATCH ?`; - params.length = 0; - params.push(q); - - if (serial) { - sql += ' AND serial_number LIKE ?'; - params.push(serial.includes('%') ? serial : `%${serial}%`); - } - if (model) { - sql += ' AND model_number LIKE ?'; - params.push(model.includes('%') ? model : `%${model}%`); - } - if (station) { - sql += ' AND test_station = ?'; - params.push(station); - } - if (logtype) { - sql += ' AND log_type = ?'; - params.push(logtype); - } - if (result) { - sql += ' AND overall_result = ?'; - params.push(result.toUpperCase()); - } - if (from) { - sql += ' AND test_date >= ?'; - params.push(from); - } - if (to) { - sql += ' AND test_date <= ?'; - params.push(to); - } + if (req.query.web_status === 'off') { + conditions.push('api_uploaded_at IS NULL'); + } else if (req.query.web_status === 'on') { + conditions.push('api_uploaded_at IS NOT NULL'); } - sql += ' ORDER BY test_date DESC, serial_number'; - sql += ' LIMIT ? OFFSET ?'; - params.push(limit, offset); + const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; - const records = db.prepare(sql).all(...params); + // whitelisted sort (prevents injection); NULLS LAST so e.g. unpublished rows don't lead an api_uploaded_at sort + const SORTABLE = { serial_number:'serial_number', model_number:'model_number', test_date:'test_date', overall_result:'overall_result', test_station:'test_station', log_type:'log_type', api_uploaded_at:'api_uploaded_at' }; + const sortCol = SORTABLE[req.query.sort]; + const sortDir = String(req.query.dir || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; + const orderBy = sortCol ? `ORDER BY ${sortCol} ${sortDir} NULLS LAST, serial_number` : 'ORDER BY test_date DESC, serial_number'; + const dataSql = `SELECT * FROM test_records ${where} ${orderBy} LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}`; + const countSql = `SELECT COUNT(*) as count FROM test_records ${where}`; + const countParams = params.slice(0, paramIdx - 2); // exclude limit/offset - // Get total count - let countSql = sql.replace(/SELECT .* FROM/, 'SELECT COUNT(*) as count FROM') - .replace(/ORDER BY.*$/, ''); - countSql = countSql.replace(/LIMIT \? OFFSET \?/, ''); - - const countParams = params.slice(0, -2); - const total = db.prepare(countSql).get(...countParams); + const [records, countRow] = await Promise.all([ + db.query(dataSql, params), + db.queryOne(countSql, countParams), + ]); res.json({ records, - total: total?.count || records.length, + total: countRow?.count ? parseInt(countRow.count, 10) : records.length, limit, offset }); @@ -156,9 +127,9 @@ router.get('/search', (req, res) => { // GET /api/record/:id // Get single record by ID // --------------------------------------------------------------------------- -router.get('/record/:id', (req, res) => { +router.get('/record/:id', async (req, res) => { try { - const record = db.prepare('SELECT * FROM test_records WHERE id = ?').get(req.params.id); + const record = await db.queryOne('SELECT * FROM test_records WHERE id = $1', [req.params.id]); if (!record) { return res.status(404).json({ error: 'Record not found' }); @@ -176,21 +147,102 @@ router.get('/record/:id', (req, res) => { // Generate datasheet for a record // Query params: format (html, txt) // --------------------------------------------------------------------------- -router.get('/datasheet/:id', (req, res) => { +router.get('/datasheet/:id', async (req, res) => { try { - const record = db.prepare('SELECT * FROM test_records WHERE id = ?').get(req.params.id); + const record = await db.queryOne('SELECT * FROM test_records WHERE id = $1', [req.params.id]); if (!record) { return res.status(404).json({ error: 'Record not found' }); } const format = req.query.format || 'html'; - const datasheet = generateDatasheet(record, format); - if (format === 'html') { - res.type('html').send(datasheet); + // Try exact-match formatter first + const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader'); + const { generateExactDatasheet } = require('../templates/datasheet-exact'); + const specMap = loadAllSpecs(); + const specs = getSpecs(specMap, record.model_number); + const exactTxt = generateExactDatasheet(record, specs); + + if (exactTxt && format === 'html') { + // Render exact-match TXT as styled HTML page + const escaped = exactTxt + .replace(/&/g, '&') + .replace(//g, '>'); + const html = ` + + + Test Data Sheet - ${record.serial_number} + + + +
+ + + +
+
+
${escaped}
+
+ +`; + res.type('html').send(html); + } else if (exactTxt && format === 'txt') { + res.type('text/plain').send(exactTxt); } else { - res.type('text/plain').send(datasheet); + // Fall back to generic template + const datasheet = generateDatasheet(record, format); + if (format === 'html') { + res.type('html').send(datasheet); + } else { + res.type('text/plain').send(datasheet); + } } } catch (err) { console.error(`[${new Date().toISOString()}] [DATASHEET ERROR] ${err.message}`); @@ -198,45 +250,83 @@ router.get('/datasheet/:id', (req, res) => { } }); +// --------------------------------------------------------------------------- +// GET /api/datasheet/:id/pdf +// Generate PDF datasheet for a record (on-demand download) +// --------------------------------------------------------------------------- +router.get('/datasheet/:id/pdf', async (req, res) => { + try { + const record = await db.queryOne('SELECT * FROM test_records WHERE id = $1', [req.params.id]); + + if (!record) { + return res.status(404).json({ error: 'Record not found' }); + } + + const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader'); + const { generateExactDatasheet } = require('../templates/datasheet-exact'); + const PDFDocument = require('pdfkit'); + + const specMap = loadAllSpecs(); + const specs = getSpecs(specMap, record.model_number); + let txt = generateExactDatasheet(record, specs); + + // Fall back to generic datasheet if exact-match formatter doesn't support this family + if (!txt) { + txt = generateDatasheet(record, 'txt'); + } + + if (!txt) { + return res.status(422).json({ error: 'Could not generate datasheet (missing specs or data)' }); + } + + const doc = new PDFDocument({ + size: 'LETTER', + margins: { top: 36, bottom: 36, left: 36, right: 36 } + }); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${record.serial_number}.pdf"`); + doc.pipe(res); + + doc.font('Courier').fontSize(9.5); + const lines = txt.split(/\r?\n/); + for (const line of lines) { + doc.text(line, { lineGap: 1 }); + } + + doc.end(); + } catch (err) { + console.error(`[${new Date().toISOString()}] [PDF ERROR] ${err.message}`); + res.status(500).json({ error: err.message }); + } +}); + // --------------------------------------------------------------------------- // GET /api/stats // Get database statistics // --------------------------------------------------------------------------- -router.get('/stats', (req, res) => { +router.get('/stats', async (req, res) => { try { - const stats = { - total_records: db.prepare('SELECT COUNT(*) as count FROM test_records').get().count, - by_log_type: db.prepare(` - SELECT log_type, COUNT(*) as count - FROM test_records - GROUP BY log_type - ORDER BY count DESC - `).all(), - by_result: db.prepare(` - SELECT overall_result, COUNT(*) as count - FROM test_records - GROUP BY overall_result - `).all(), - by_station: db.prepare(` - SELECT test_station, COUNT(*) as count - FROM test_records - WHERE test_station IS NOT NULL AND test_station != '' - GROUP BY test_station - ORDER BY test_station - `).all(), - date_range: db.prepare(` - SELECT MIN(test_date) as oldest, MAX(test_date) as newest - FROM test_records - `).get(), - recent_serials: db.prepare(` - SELECT DISTINCT serial_number, model_number, test_date - FROM test_records - ORDER BY test_date DESC - LIMIT 10 - `).all() - }; + const [totalRow, byLogType, byResult, byStation, dateRange, recentSerials] = await Promise.all([ + db.queryOne('SELECT COUNT(*) as count FROM test_records'), + db.query('SELECT log_type, COUNT(*) as count FROM test_records GROUP BY log_type ORDER BY count DESC'), + db.query('SELECT overall_result, COUNT(*) as count FROM test_records GROUP BY overall_result'), + db.query(`SELECT test_station, COUNT(*) as count FROM test_records + WHERE test_station IS NOT NULL AND test_station != '' + GROUP BY test_station ORDER BY test_station`), + db.queryOne('SELECT MIN(test_date) as oldest, MAX(test_date) as newest FROM test_records'), + db.query(`SELECT DISTINCT serial_number, model_number, test_date + FROM test_records ORDER BY test_date DESC LIMIT 10`), + ]); - res.json(stats); + res.json({ + total_records: parseInt(totalRow.count, 10), + by_log_type: byLogType.map(r => ({ ...r, count: parseInt(r.count, 10) })), + by_result: byResult.map(r => ({ ...r, count: parseInt(r.count, 10) })), + by_station: byStation.map(r => ({ ...r, count: parseInt(r.count, 10) })), + date_range: dateRange, + recent_serials: recentSerials, + }); } catch (err) { console.error(`[${new Date().toISOString()}] [STATS ERROR] ${err.message}`); res.status(500).json({ error: err.message }); @@ -247,30 +337,22 @@ router.get('/stats', (req, res) => { // GET /api/filters // Get available filter options (test stations, log types, models) // --------------------------------------------------------------------------- -router.get('/filters', (req, res) => { +router.get('/filters', async (req, res) => { try { - const filters = { - stations: db.prepare(` - SELECT DISTINCT test_station - FROM test_records - WHERE test_station IS NOT NULL AND test_station != '' - ORDER BY test_station - `).all().map(r => r.test_station), - log_types: db.prepare(` - SELECT DISTINCT log_type - FROM test_records - ORDER BY log_type - `).all().map(r => r.log_type), - models: db.prepare(` - SELECT DISTINCT model_number, COUNT(*) as count - FROM test_records - GROUP BY model_number - ORDER BY count DESC - LIMIT 500 - `).all() - }; + const [stations, logTypes, models] = await Promise.all([ + db.query(`SELECT DISTINCT test_station FROM test_records + WHERE test_station IS NOT NULL AND test_station != '' + ORDER BY test_station`), + db.query('SELECT DISTINCT log_type FROM test_records ORDER BY log_type'), + db.query(`SELECT DISTINCT model_number, COUNT(*) as count FROM test_records + GROUP BY model_number ORDER BY count DESC LIMIT 500`), + ]); - res.json(filters); + res.json({ + stations: stations.map(r => r.test_station), + log_types: logTypes.map(r => r.log_type), + models: models.map(r => ({ ...r, count: parseInt(r.count, 10) })), + }); } catch (err) { console.error(`[${new Date().toISOString()}] [FILTERS ERROR] ${err.message}`); res.status(500).json({ error: err.message }); @@ -281,51 +363,60 @@ router.get('/filters', (req, res) => { // GET /api/export // Export search results as CSV // --------------------------------------------------------------------------- -router.get('/export', (req, res) => { +router.get('/export', async (req, res) => { try { const { serial, model, from, to, result, station, logtype } = req.query; - let sql = 'SELECT * FROM test_records WHERE 1=1'; + const conditions = []; const params = []; + let paramIdx = 0; + + const addParam = (val) => { + paramIdx++; + params.push(val); + return '$' + paramIdx; + }; if (serial) { - sql += ' AND serial_number LIKE ?'; - params.push(serial.includes('%') ? serial : `%${serial}%`); + const val = serial.includes('%') ? serial : `%${serial}%`; + conditions.push(`serial_number LIKE ${addParam(val)}`); } if (model) { - sql += ' AND model_number LIKE ?'; - params.push(model.includes('%') ? model : `%${model}%`); + const val = model.includes('%') ? model : `%${model}%`; + conditions.push(`model_number LIKE ${addParam(val)}`); } if (from) { - sql += ' AND test_date >= ?'; - params.push(from); + conditions.push(`test_date >= ${addParam(from)}`); } if (to) { - sql += ' AND test_date <= ?'; - params.push(to); + conditions.push(`test_date <= ${addParam(to)}`); } if (result) { - sql += ' AND overall_result = ?'; - params.push(result.toUpperCase()); + conditions.push(`overall_result = ${addParam(result.toUpperCase())}`); } if (station) { - sql += ' AND test_station = ?'; - params.push(station); + conditions.push(`test_station = ${addParam(station)}`); } if (logtype) { - sql += ' AND log_type = ?'; - params.push(logtype); + conditions.push(`log_type = ${addParam(logtype)}`); } - sql += ' ORDER BY test_date DESC, serial_number LIMIT 10000'; + if (req.query.web_status === 'off') { + conditions.push('api_uploaded_at IS NULL'); + } else if (req.query.web_status === 'on') { + conditions.push('api_uploaded_at IS NOT NULL'); + } - const records = db.prepare(sql).all(...params); + const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; + const sql = `SELECT * FROM test_records ${where} ORDER BY test_date DESC, serial_number LIMIT 10000`; + + const records = await db.query(sql, params); // Generate CSV const headers = ['id', 'log_type', 'model_number', 'serial_number', 'test_date', 'test_station', 'overall_result', 'source_file']; @@ -348,16 +439,119 @@ router.get('/export', (req, res) => { } }); +// --------------------------------------------------------------------------- +// GET /api/workorder/:wo +// Get work order details and all associated test lines +// --------------------------------------------------------------------------- +router.get('/workorder/:wo', async (req, res) => { + try { + const wo = req.params.wo; + + const [header, lines, testRecords] = await Promise.all([ + db.queryOne('SELECT * FROM work_orders WHERE wo_number = $1', [wo]), + db.query('SELECT * FROM work_order_lines WHERE wo_number = $1 ORDER BY test_date, test_time', [wo]), + db.query( + 'SELECT id, log_type, model_number, serial_number, test_date, test_station, overall_result, work_order FROM test_records WHERE work_order = $1 ORDER BY serial_number', + [wo] + ), + ]); + + res.json({ + work_order: header || { wo_number: wo }, + lines, + test_records: testRecords, + }); + } catch (err) { + console.error(`[${new Date().toISOString()}] [WO ERROR] ${err.message}`); + res.status(500).json({ error: err.message }); + } +}); + +// --------------------------------------------------------------------------- +// GET /api/workorder-search?q= +// Search work orders by number (prefix match) +// --------------------------------------------------------------------------- +router.get('/workorder-search', async (req, res) => { + try { + const q = req.query.q || ''; + if (q.length < 2) { + return res.json({ results: [] }); + } + + const results = await db.query( + 'SELECT wo_number, wo_date, program, test_station FROM work_orders WHERE wo_number LIKE $1 ORDER BY wo_date DESC LIMIT 50', + [q + '%'] + ); + + res.json({ results }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // --------------------------------------------------------------------------- // Cleanup function for graceful shutdown // --------------------------------------------------------------------------- -function cleanup() { +async function cleanup() { try { - db.close(); + await db.close(); } catch (err) { console.error(`[${new Date().toISOString()}] [CLEANUP ERROR] ${err.message}`); } } +/** + * POST /api/upload + * + * Body: { ids?: number[], serialNumbers?: string[], all_unuploaded?: boolean } + * + * Pushes selected records to the Dataforth website API. Accepts either a set + * of record IDs (resolved to serial_number + checked for exported status), a + * direct list of serial numbers, or all_unuploaded:true to push every PASS + * record where api_uploaded_at IS NULL. + * + * Response: { created, updated, unchanged, errors, skipped, processed, sns } + */ +router.post('/upload', async (req, res) => { + try { + const { ids, serialNumbers, all_unuploaded } = req.body || {}; + const { uploadBySerialNumbers } = require('../database/upload-to-api'); + + let sns = []; + if (all_unuploaded) { + const rows = await db.query( + `SELECT DISTINCT serial_number FROM test_records + WHERE overall_result = 'PASS' + AND api_uploaded_at IS NULL + ORDER BY serial_number` + ); + sns = rows.map(r => r.serial_number); + } else if (Array.isArray(ids) && ids.length > 0) { + const placeholders = ids.map((_, i) => `$${i + 1}`).join(','); + const rows = await db.query( + `SELECT DISTINCT serial_number FROM test_records + WHERE id IN (${placeholders}) + AND overall_result = 'PASS'`, + ids, + ); + sns = rows.map(r => r.serial_number); + } else if (Array.isArray(serialNumbers) && serialNumbers.length > 0) { + sns = [...new Set(serialNumbers)]; + } else { + return res.status(400).json({ error: 'provide ids[], serialNumbers[], or all_unuploaded=true' }); + } + + if (sns.length === 0) { + return res.json({ created:0, updated:0, unchanged:0, errors:0, skipped:0, processed:0, sns:[] }); + } + + const result = await uploadBySerialNumbers(sns); + res.json({ ...result, processed: sns.length, sns }); + } catch (err) { + console.error(`[UPLOAD] ${err.message}`); + res.status(500).json({ error: err.message }); + } +}); + module.exports = router; module.exports.cleanup = cleanup; diff --git a/projects/dataforth-dos/tools/preview-proxy.py b/projects/dataforth-dos/tools/preview-proxy.py index 4fe18223..06a22851 100644 --- a/projects/dataforth-dos/tools/preview-proxy.py +++ b/projects/dataforth-dos/tools/preview-proxy.py @@ -37,6 +37,23 @@ class H(http.server.BaseHTTPRequestHandler): else: self._send(404, "text/plain", b"not found") + def do_POST(self): + if self.path.startswith("/api/"): + n = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(n) if n else b"" + req = urllib.request.Request( + TARGET + self.path, data=body, method="POST", + headers={"Content-Type": self.headers.get("Content-Type", "application/json")}) + try: + with urllib.request.urlopen(req, timeout=120) as r: + self._send(200, r.headers.get("Content-Type", "application/json"), r.read()) + except urllib.error.HTTPError as e: + self._send(e.code, "application/json", e.read()) + except Exception as e: + self._send(502, "text/plain", str(e).encode()) + else: + self._send(404, "text/plain", b"not found") + def _send(self, code, ct, body): self.send_response(code) self.send_header("Content-Type", ct)