Files
travandClaude Opus 5 d1cb472696 Initial commit: repair cafe kanban board
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>
2026-08-15 15:02:45 -04:00

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 };