Dataforth (projects/dataforth-dos/): - UI feature: row coloring + PUSH/RE-PUSH buttons + Website Status filter - Database dedup to one row per SN (2.89M -> 469K rows, UNIQUE constraint added) - Import logic handles FAIL -> PASS retest transition - Refactored upload-to-api.js to render datasheets in-memory (dropped For_Web filesystem dep) - Bulk pushed 170,984 records to Hoffman API - Statistical sanity check: 100/100 stamped SNs verified on Hoffman GuruRMM (projects/msp-tools/guru-rmm/): - ROADMAP.md: added Terminology (5-tier hierarchy), Tunnel Channels Phase 2, Logging/Audit/Observability, Multi-tenancy, Modular Architecture, Protocol Versioning, Certificates sections + Decisions Log - CONTEXT.md: hierarchy table, new anti-patterns (bootstrap sacred, no cross-module imports), revised next-steps priorities Session logs for both projects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
553 lines
20 KiB
JavaScript
553 lines
20 KiB
JavaScript
/**
|
|
* 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, '<')
|
|
.replace(/>/g, '>');
|
|
const html = `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Test Data Sheet - ${record.serial_number}</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: #f0f0f0;
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
.page {
|
|
background: white;
|
|
padding: 40px 30px;
|
|
max-width: 720px;
|
|
width: 100%;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
border: 1px solid #ccc;
|
|
}
|
|
pre {
|
|
font-family: 'Courier New', Courier, monospace;
|
|
font-size: 11px;
|
|
line-height: 1.4;
|
|
margin: 0;
|
|
white-space: pre;
|
|
overflow-x: auto;
|
|
}
|
|
.toolbar {
|
|
position: fixed;
|
|
top: 10px;
|
|
right: 10px;
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.toolbar button {
|
|
padding: 8px 16px;
|
|
border: 1px solid #999;
|
|
background: white;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
border-radius: 4px;
|
|
}
|
|
.toolbar button:hover { background: #e0e0e0; }
|
|
@media print {
|
|
body { background: white; padding: 0; }
|
|
.page { box-shadow: none; border: none; padding: 0; }
|
|
.toolbar { display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="toolbar">
|
|
<button onclick="window.print()">Print</button>
|
|
<button onclick="window.open('/api/datasheet/${record.id}/pdf')">Download PDF</button>
|
|
<button onclick="window.close()">Close</button>
|
|
</div>
|
|
<div class="page">
|
|
<pre>${escaped}</pre>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
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=<query>
|
|
// 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;
|