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