/** * API Routes for Test Data Database * * 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 db = require('../database/db'); const { generateDatasheet } = require('../templates/datasheet'); const router = express.Router(); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const MAX_LIMIT = 1000; function clampLimit(value) { const parsed = parseInt(value, 10); if (isNaN(parsed) || parsed < 1) return 100; return Math.min(parsed, MAX_LIMIT); } function clampOffset(value) { const parsed = parseInt(value, 10); if (isNaN(parsed) || parsed < 0) return 0; return parsed; } // --------------------------------------------------------------------------- // GET /api/search // Search test records // Query params: serial, model, from, to, result, q, station, logtype, web_status, limit, offset // --------------------------------------------------------------------------- router.get('/search', async (req, res) => { try { 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); 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) { const val = serial.includes('%') ? serial : `%${serial}%`; conditions.push(`serial_number LIKE ${addParam(val)}`); } if (workorder) { conditions.push(`work_order = ${addParam(workorder)}`); } if (model) { const val = model.includes('%') ? model : `%${model}%`; conditions.push(`model_number LIKE ${addParam(val)}`); } if (from) { conditions.push(`test_date >= ${addParam(from)}`); } if (to) { conditions.push(`test_date <= ${addParam(to)}`); } if (result) { conditions.push(`overall_result = ${addParam(result.toUpperCase())}`); } if (station) { conditions.push(`test_station = ${addParam(station)}`); } if (logtype) { conditions.push(`log_type = ${addParam(logtype)}`); } 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 where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; const dataSql = `SELECT * FROM test_records ${where} ORDER BY test_date DESC, serial_number 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 const [records, countRow] = await Promise.all([ db.query(dataSql, params), db.queryOne(countSql, countParams), ]); res.json({ records, total: countRow?.count ? parseInt(countRow.count, 10) : records.length, limit, offset }); } catch (err) { console.error(`[${new Date().toISOString()}] [SEARCH ERROR] ${err.message}`); res.status(500).json({ error: err.message }); } }); // --------------------------------------------------------------------------- // GET /api/record/:id // Get single record by ID // --------------------------------------------------------------------------- router.get('/record/:id', 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' }); } res.json(record); } catch (err) { console.error(`[${new Date().toISOString()}] [RECORD ERROR] ${err.message}`); res.status(500).json({ error: err.message }); } }); // --------------------------------------------------------------------------- // GET /api/datasheet/:id // Generate datasheet for a record // Query params: format (html, txt) // --------------------------------------------------------------------------- router.get('/datasheet/:id', 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 format = req.query.format || 'html'; // 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 { // 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}`); res.status(500).json({ error: err.message }); } }); // --------------------------------------------------------------------------- // 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', async (req, res) => { try { 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({ 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 }); } }); // --------------------------------------------------------------------------- // GET /api/filters // Get available filter options (test stations, log types, models) // --------------------------------------------------------------------------- router.get('/filters', async (req, res) => { try { 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({ 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 }); } }); // --------------------------------------------------------------------------- // GET /api/export // Export search results as CSV // --------------------------------------------------------------------------- router.get('/export', async (req, res) => { try { const { serial, model, from, to, result, station, logtype } = req.query; const conditions = []; const params = []; let paramIdx = 0; const addParam = (val) => { paramIdx++; params.push(val); return '$' + paramIdx; }; if (serial) { const val = serial.includes('%') ? serial : `%${serial}%`; conditions.push(`serial_number LIKE ${addParam(val)}`); } if (model) { const val = model.includes('%') ? model : `%${model}%`; conditions.push(`model_number LIKE ${addParam(val)}`); } if (from) { conditions.push(`test_date >= ${addParam(from)}`); } if (to) { conditions.push(`test_date <= ${addParam(to)}`); } if (result) { conditions.push(`overall_result = ${addParam(result.toUpperCase())}`); } if (station) { conditions.push(`test_station = ${addParam(station)}`); } if (logtype) { conditions.push(`log_type = ${addParam(logtype)}`); } 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 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']; let csv = headers.join(',') + '\n'; for (const record of records) { const row = headers.map(h => { const val = record[h] || ''; return `"${String(val).replace(/"/g, '""')}"`; }); csv += row.join(',') + '\n'; } res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', 'attachment; filename=test_records.csv'); res.send(csv); } catch (err) { console.error(`[${new Date().toISOString()}] [EXPORT ERROR] ${err.message}`); res.status(500).json({ error: err.message }); } }); // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- async function cleanup() { try { 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;