A per-event queue board for a repair cafe. Customers sign items in on a shared tablet; volunteers drag repairs through Queue, In progress and Done on a dashboard, then export the day's records as CSV. Zero npm dependencies: node:http plus JSON files on disk, no build step. Categories and house rules are set in config.json. Events are deleted 7 days after creation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
239 lines
7.7 KiB
JavaScript
239 lines
7.7 KiB
JavaScript
'use strict';
|
|
|
|
const http = require('node:http');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const store = require('./lib/store');
|
|
const { toCsv } = require('./lib/csv');
|
|
|
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ replies */
|
|
|
|
function sendJson(res, status, value) {
|
|
const body = JSON.stringify(value);
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function sendPage(res, file, status = 200) {
|
|
fs.readFile(path.join(PUBLIC_DIR, file), (err, body) => {
|
|
if (err) return sendText(res, 500, 'Could not read page.');
|
|
res.writeHead(status, {
|
|
'Content-Type': MIME['.html'],
|
|
'Content-Length': body.length,
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(body);
|
|
});
|
|
}
|
|
|
|
function sendText(res, status, message) {
|
|
const body = `${message}\n`;
|
|
res.writeHead(status, {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function sendCsv(res, filename, csv) {
|
|
const body = Buffer.from(csv, 'utf8');
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/csv; charset=utf-8',
|
|
'Content-Length': body.length,
|
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function sendStatic(res, name) {
|
|
const file = path.join(PUBLIC_DIR, name);
|
|
// Never let a crafted path climb out of public/.
|
|
if (!file.startsWith(PUBLIC_DIR + path.sep)) return sendText(res, 403, 'Forbidden');
|
|
|
|
fs.readFile(file, (err, body) => {
|
|
if (err) return sendText(res, 404, 'Not found');
|
|
res.writeHead(200, {
|
|
'Content-Type': MIME[path.extname(file)] || 'application/octet-stream',
|
|
'Content-Length': body.length,
|
|
'Cache-Control': 'no-cache',
|
|
});
|
|
res.end(body);
|
|
});
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let raw = '';
|
|
req.on('data', (chunk) => {
|
|
raw += chunk;
|
|
// Nothing here should ever be large; refuse rather than buffer forever.
|
|
if (raw.length > 256 * 1024) reject(new Error('Request too large.'));
|
|
});
|
|
req.on('error', reject);
|
|
req.on('end', () => {
|
|
if (!raw) return resolve({});
|
|
try {
|
|
resolve(JSON.parse(raw));
|
|
} catch {
|
|
reject(new Error('Expected JSON.'));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- router */
|
|
|
|
// Resolve a slug to an event, replying for the caller if it is gone.
|
|
function requireEvent(res, slug, asJson) {
|
|
const event = store.loadEvent(slug);
|
|
if (event) return event;
|
|
|
|
if (store.isExpired(slug)) {
|
|
if (asJson) sendJson(res, 410, { error: 'This event has expired and its data has been deleted.' });
|
|
else sendPage(res, 'expired.html', 410);
|
|
} else if (asJson) {
|
|
sendJson(res, 404, { error: 'No such event.' });
|
|
} else {
|
|
sendPage(res, 'notfound.html', 404);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function route(req, res) {
|
|
const url = new URL(req.url, 'http://localhost');
|
|
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
const method = req.method;
|
|
|
|
/* pages and assets */
|
|
if (method === 'GET' && parts.length === 0) return sendPage(res, 'index.html');
|
|
if (method === 'GET' && parts.length === 1 && parts[0] === 'new') return sendPage(res, 'new.html');
|
|
if (method === 'GET' && parts[0] === 'static' && parts.length === 2) return sendStatic(res, parts[1]);
|
|
|
|
/* all-events CSV */
|
|
if (method === 'GET' && parts.length === 1 && parts[0] === 'export.csv') {
|
|
return sendCsv(res, 'repair-cafe-all-events.csv', toCsv(store.listEvents()));
|
|
}
|
|
|
|
/* api */
|
|
if (parts[0] === 'api') {
|
|
if (method === 'GET' && parts[1] === 'config' && parts.length === 2) {
|
|
const { categories, houseRules, retentionDays } = store.getConfig();
|
|
return sendJson(res, 200, { categories, houseRules, retentionDays });
|
|
}
|
|
|
|
if (method === 'POST' && parts[1] === 'events' && parts.length === 2) {
|
|
const body = await readBody(req);
|
|
const event = store.createEvent(body.name, body.randomize === true);
|
|
return sendJson(res, 201, { slug: event.slug, name: event.name });
|
|
}
|
|
|
|
if (parts[1] === 'events' && parts.length >= 3) {
|
|
const slug = parts[2];
|
|
|
|
if (method === 'GET' && parts.length === 3) {
|
|
const event = requireEvent(res, slug, true);
|
|
if (!event) return;
|
|
return sendJson(res, 200, { ...event, expiresAt: store.expiresAt(event) });
|
|
}
|
|
|
|
if (method === 'POST' && parts[3] === 'repairs' && parts.length === 4) {
|
|
const event = requireEvent(res, slug, true);
|
|
if (!event) return;
|
|
return sendJson(res, 201, store.addRepair(event, await readBody(req)));
|
|
}
|
|
|
|
if (method === 'PATCH' && parts[3] === 'repairs' && parts.length === 5) {
|
|
const event = requireEvent(res, slug, true);
|
|
if (!event) return;
|
|
const repair = store.updateRepair(event, parts[4], await readBody(req));
|
|
if (!repair) return sendJson(res, 404, { error: 'No such repair.' });
|
|
return sendJson(res, 200, repair);
|
|
}
|
|
}
|
|
|
|
return sendJson(res, 404, { error: 'No such endpoint.' });
|
|
}
|
|
|
|
/* event pages */
|
|
if (method === 'GET' && parts.length >= 1 && parts.length <= 2) {
|
|
const slug = parts[0];
|
|
if (slug !== store.slugify(slug)) return sendPage(res, 'notfound.html', 404);
|
|
|
|
if (parts.length === 1) {
|
|
if (!requireEvent(res, slug, false)) return;
|
|
return sendPage(res, 'intake.html');
|
|
}
|
|
if (parts[1] === 'dashboard') {
|
|
if (!requireEvent(res, slug, false)) return;
|
|
return sendPage(res, 'dashboard.html');
|
|
}
|
|
if (parts[1] === 'export.csv') {
|
|
const event = requireEvent(res, slug, false);
|
|
if (!event) return;
|
|
return sendCsv(res, `${slug}.csv`, toCsv([event]));
|
|
}
|
|
}
|
|
|
|
return sendPage(res, 'notfound.html', 404);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- server */
|
|
|
|
const server = http.createServer((req, res) => {
|
|
route(req, res).catch((err) => {
|
|
// Validation failures from the store are the user's to fix; anything else
|
|
// is ours, so log it.
|
|
const isValidation = err instanceof Error && !!err.message && err.message.length < 300;
|
|
if (!isValidation) console.error(err);
|
|
if (res.headersSent) return res.end();
|
|
if (req.url.startsWith('/api/')) sendJson(res, 400, { error: err.message || 'Something went wrong.' });
|
|
else sendText(res, 400, err.message || 'Something went wrong.');
|
|
});
|
|
});
|
|
|
|
function start() {
|
|
const config = store.loadConfig();
|
|
|
|
const pruned = store.pruneExpired();
|
|
if (pruned.length) console.log(`Deleted ${pruned.length} expired event(s): ${pruned.join(', ')}`);
|
|
|
|
// Re-check daily for long-running servers. unref() so this timer alone never
|
|
// keeps the process alive.
|
|
setInterval(() => {
|
|
const gone = store.pruneExpired();
|
|
if (gone.length) console.log(`Deleted ${gone.length} expired event(s): ${gone.join(', ')}`);
|
|
}, DAY_MS).unref();
|
|
|
|
const port = Number(process.env.PORT) || config.port || 8080;
|
|
const host = process.env.HOST || '0.0.0.0';
|
|
server.listen(port, host, () => {
|
|
console.log(`Repair Cafe Kanban listening on http://localhost:${port}`);
|
|
console.log(
|
|
config.retentionDays > 0
|
|
? `Events are deleted ${config.retentionDays} days after they are created.`
|
|
: 'Event retention is off — nothing is deleted automatically.'
|
|
);
|
|
});
|
|
}
|
|
|
|
if (require.main === module) start();
|
|
|
|
module.exports = { server, start };
|