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>
268 lines
7.0 KiB
JavaScript
268 lines
7.0 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const DATA_DIR = path.join(ROOT, 'data');
|
|
const CONFIG_PATH = path.join(ROOT, 'config.json');
|
|
|
|
// Slugs are stripped to [a-z0-9-], so no event file can ever be called this.
|
|
const EXPIRED_PATH = path.join(DATA_DIR, 'expired.json');
|
|
|
|
// URL segments the router needs for itself.
|
|
const RESERVED_SLUGS = new Set(['new', 'api', 'static', 'export.csv', 'dashboard', 'data', 'expired']);
|
|
|
|
const STATUSES = ['queue', 'in-progress', 'done'];
|
|
const OUTCOMES = ['fixed', 'partially fixed', 'not fixed'];
|
|
|
|
let config = null;
|
|
|
|
function loadConfig() {
|
|
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
if (!Array.isArray(config.categories) || config.categories.length === 0) {
|
|
throw new Error('config.json must list at least one category');
|
|
}
|
|
return config;
|
|
}
|
|
|
|
function getConfig() {
|
|
if (!config) loadConfig();
|
|
return config;
|
|
}
|
|
|
|
function categoryNames() {
|
|
return getConfig().categories.map((c) => c.name);
|
|
}
|
|
|
|
function ensureDataDir() {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
// "Saturday Repair Day!" -> "saturday-repair-day"
|
|
function slugify(name) {
|
|
return String(name || '')
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 60);
|
|
}
|
|
|
|
function randomSuffix() {
|
|
// Base32-ish alphabet with no vowels or lookalikes, so a suffix read aloud
|
|
// over a noisy room doesn't turn into a different event.
|
|
const alphabet = '23456789bcdfghjkmnpqrstvwxz';
|
|
let out = '';
|
|
const bytes = crypto.randomBytes(4);
|
|
for (const b of bytes) out += alphabet[b % alphabet.length];
|
|
return out;
|
|
}
|
|
|
|
function eventPath(slug) {
|
|
return path.join(DATA_DIR, `${slug}.json`);
|
|
}
|
|
|
|
function eventExists(slug) {
|
|
return fs.existsSync(eventPath(slug));
|
|
}
|
|
|
|
// Write to a temp file in the same directory, then rename over the target, so a
|
|
// crash mid-write can never leave a half-written event behind.
|
|
function writeJson(file, value) {
|
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
|
|
function readExpired() {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(EXPIRED_PATH, 'utf8'));
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function isExpired(slug) {
|
|
return Object.prototype.hasOwnProperty.call(readExpired(), slug);
|
|
}
|
|
|
|
function listSlugs() {
|
|
ensureDataDir();
|
|
return fs
|
|
.readdirSync(DATA_DIR)
|
|
.filter((f) => f.endsWith('.json') && f !== 'expired.json')
|
|
.map((f) => f.slice(0, -5))
|
|
.sort();
|
|
}
|
|
|
|
function loadEvent(slug) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(eventPath(slug), 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveEvent(event) {
|
|
ensureDataDir();
|
|
writeJson(eventPath(event.slug), event);
|
|
return event;
|
|
}
|
|
|
|
function listEvents() {
|
|
return listSlugs()
|
|
.map(loadEvent)
|
|
.filter(Boolean)
|
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
}
|
|
|
|
function createEvent(name, randomize) {
|
|
ensureDataDir();
|
|
const base = slugify(name);
|
|
if (!base) throw new Error('Please give the event a name.');
|
|
|
|
let slug = randomize ? `${base}-${randomSuffix()}` : base;
|
|
// Always resolve a collision, even when the box was unchecked — two events
|
|
// sharing a URL would silently merge their queues.
|
|
while (RESERVED_SLUGS.has(slug) || eventExists(slug) || isExpired(slug)) {
|
|
slug = `${base}-${randomSuffix()}`;
|
|
}
|
|
|
|
return saveEvent({
|
|
slug,
|
|
name: String(name).trim(),
|
|
createdAt: new Date().toISOString(),
|
|
repairs: [],
|
|
});
|
|
}
|
|
|
|
function newId() {
|
|
return 'r_' + crypto.randomBytes(4).toString('hex');
|
|
}
|
|
|
|
function addRepair(event, input) {
|
|
const category = String(input.category || '').trim().toLowerCase();
|
|
if (!categoryNames().includes(category)) {
|
|
throw new Error(`"${input.category}" is not one of the categories in config.json.`);
|
|
}
|
|
const customer = String(input.customer || '').trim();
|
|
const item = String(input.item || '').trim();
|
|
if (!customer) throw new Error('Please give the customer name.');
|
|
if (!item) throw new Error('Please give the item name.');
|
|
if (input.agreedRules !== true) throw new Error('The house rules must be agreed to.');
|
|
|
|
const repair = {
|
|
id: newId(),
|
|
createdAt: new Date().toISOString(),
|
|
customer,
|
|
item,
|
|
category,
|
|
problem: String(input.problem || '').trim(),
|
|
status: 'queue',
|
|
repairer: null,
|
|
outcome: null,
|
|
toolsUsed: '',
|
|
notes: [],
|
|
};
|
|
event.repairs.push(repair);
|
|
saveEvent(event);
|
|
return repair;
|
|
}
|
|
|
|
const EDITABLE_TEXT = ['customer', 'item', 'problem', 'repairer', 'toolsUsed'];
|
|
|
|
function updateRepair(event, id, patch) {
|
|
const repair = event.repairs.find((r) => r.id === id);
|
|
if (!repair) return null;
|
|
|
|
for (const field of EDITABLE_TEXT) {
|
|
if (patch[field] !== undefined) {
|
|
const value = patch[field] === null ? '' : String(patch[field]).trim();
|
|
repair[field] = field === 'repairer' ? value || null : value;
|
|
}
|
|
}
|
|
|
|
if (patch.category !== undefined) {
|
|
const category = String(patch.category).trim().toLowerCase();
|
|
if (!categoryNames().includes(category)) {
|
|
throw new Error(`"${patch.category}" is not one of the categories in config.json.`);
|
|
}
|
|
repair.category = category;
|
|
}
|
|
|
|
if (patch.status !== undefined) {
|
|
if (!STATUSES.includes(patch.status)) throw new Error(`Unknown status "${patch.status}".`);
|
|
repair.status = patch.status;
|
|
}
|
|
|
|
if (patch.outcome !== undefined) {
|
|
const outcome = patch.outcome === null || patch.outcome === '' ? null : String(patch.outcome);
|
|
if (outcome !== null && !OUTCOMES.includes(outcome)) {
|
|
throw new Error(`Unknown outcome "${patch.outcome}".`);
|
|
}
|
|
repair.outcome = outcome;
|
|
}
|
|
|
|
if (typeof patch.addNote === 'string' && patch.addNote.trim()) {
|
|
repair.notes.push({ at: new Date().toISOString(), text: patch.addNote.trim() });
|
|
}
|
|
|
|
saveEvent(event);
|
|
return repair;
|
|
}
|
|
|
|
// Delete events older than retentionDays, remembering the slug so the URL can
|
|
// say "expired" rather than "not found". retentionDays: 0 disables pruning.
|
|
function pruneExpired() {
|
|
const days = Number(getConfig().retentionDays);
|
|
if (!days || days <= 0) return [];
|
|
|
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
const expired = readExpired();
|
|
const pruned = [];
|
|
|
|
for (const slug of listSlugs()) {
|
|
const event = loadEvent(slug);
|
|
if (!event) continue;
|
|
if (Date.parse(event.createdAt) < cutoff) {
|
|
fs.unlinkSync(eventPath(slug));
|
|
expired[slug] = new Date().toISOString();
|
|
pruned.push(slug);
|
|
}
|
|
}
|
|
|
|
if (pruned.length) {
|
|
ensureDataDir();
|
|
writeJson(EXPIRED_PATH, expired);
|
|
}
|
|
return pruned;
|
|
}
|
|
|
|
function expiresAt(event) {
|
|
const days = Number(getConfig().retentionDays);
|
|
if (!days || days <= 0) return null;
|
|
return new Date(Date.parse(event.createdAt) + days * 24 * 60 * 60 * 1000).toISOString();
|
|
}
|
|
|
|
module.exports = {
|
|
DATA_DIR,
|
|
ROOT,
|
|
STATUSES,
|
|
OUTCOMES,
|
|
loadConfig,
|
|
getConfig,
|
|
categoryNames,
|
|
slugify,
|
|
createEvent,
|
|
loadEvent,
|
|
saveEvent,
|
|
listEvents,
|
|
addRepair,
|
|
updateRepair,
|
|
pruneExpired,
|
|
expiresAt,
|
|
isExpired,
|
|
};
|