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}
+
+
+
+
+
+
+
+
+
+
+`;
+ 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)