Service Times 1.0.0: every campus's services, and what production can't use
Four campuses run different Sunday schedules and take their resi feed from one of two broadcast origins. When a service goes wrong — a machine crashes, a feed is unusable — the booth has to know which service at which campus can be altered, and which campuses are riding on the one that broke. That lived in people's heads and in messages. The times come from the church website rather than from configuration, because the site is what stays current when services change through the year. Each campus page carries a "Times" block; the module reads it and re-reads it on a timer. Two things on those pages are not services and are excluded on purpose: the main-office hours in every page's footer, and the "Office Hours" block Apollo Beach carries inside its own Times section. The parse is bounded to the Times heading and stops at either. A mark on a service — issue, replay, do not use, with a note, and for a resi campus which of the origin's services it is taking — is the booth's shared answer, so it lives in the module's server state and reaches every tile over SSE. Per-tile settings would let two campuses disagree about whether the 9:30 is going out. Marks are written to the data directory so a restart mid-morning does not lose them, and they expire after 36 hours so last week's never greet a new Sunday. Campuses added by hand in admin are a stopgap for a site that has not caught up yet: as soon as the website publishes that campus, the site's times take over and the admin health line says the manual line can be removed. Verified: the parser against all five live campus pages, and 31 unit tests plus 16 client checks, each proven to fail under mutation — both parse guards, the merge rule, dependent recording, and three client behaviours. The suite is run here under JavaScriptCore, since this machine has no Node. Not verified here: the module guide's resilience checklist needs a running ProdDash — upstream loss and recovery, two tiles side by side, and tile removal leaving no timers behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Service Times — server part.
|
||||
*
|
||||
* Owns the one connection to the church website, keeps every campus's Sunday
|
||||
* service times in memory, and fans them — together with the production marks
|
||||
* an operator puts on a service — out to every tile over SSE. Browsers never
|
||||
* talk to the website.
|
||||
*
|
||||
* GET /state → { state } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
* POST /mark → mark one service (replay / do not use / issue)
|
||||
* POST /clear → drop every mark (a fresh week)
|
||||
* POST /refresh → re-read the website now
|
||||
* GET /campuses → { options } — the campuses in play, for the admin
|
||||
* broadcast picker and a tile's campus list
|
||||
* GET /discovered → { options } — every campus page the website publishes,
|
||||
* for the admin "Campuses" picker
|
||||
*
|
||||
* Why the marks live here and not in a tile's settings: a mark is the booth's
|
||||
* shared answer to "is this service usable?", and every campus watching the
|
||||
* board has to see the same one. Per-tile settings are per browser, so they
|
||||
* would let two people disagree about whether the 9:30 is going out. Marks
|
||||
* are written to the ProdDash data directory so a restart mid-morning does
|
||||
* not lose them, and they expire on their own (admin "Marks") so last week's
|
||||
* never greet a new Sunday.
|
||||
*
|
||||
* The website is the source of truth for times. Manual campuses (admin) are a
|
||||
* stopgap for a campus the site has not published yet; the moment the site
|
||||
* carries it, the site's times take over and the admin page says the manual
|
||||
* line can go.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
parseCampusPage,
|
||||
discoverCampusSlugs,
|
||||
parseManualCampuses,
|
||||
mergeCampuses,
|
||||
parseFeeds,
|
||||
assignRoles,
|
||||
slugify,
|
||||
} = require('./parse');
|
||||
|
||||
/** Abort a hung page read well before the refresh cadence. */
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
/** Fastest allowed re-read: service times change a few times a year. */
|
||||
const MIN_REFRESH_MINUTES = 5;
|
||||
/** Backoff after a failed read — keep the last good times on screen meanwhile. */
|
||||
const RETRY_MS = 60000;
|
||||
/** Guard against a redirect loop or a runaway campus list. */
|
||||
const MAX_CAMPUSES = 40;
|
||||
const BODY_LIMIT_BYTES = 64 * 1024;
|
||||
|
||||
const MARK_STATUSES = new Set(['ok', 'issue', 'replay', 'skip']);
|
||||
const NOTE_LIMIT = 280;
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
function sendJson(res, code, body) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
let size = 0;
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > BODY_LIMIT_BYTES) {
|
||||
reject(new Error('Request body too large.'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!data.trim()) return resolve({});
|
||||
try {
|
||||
const body = JSON.parse(data);
|
||||
resolve(body && typeof body === 'object' ? body : {});
|
||||
} catch {
|
||||
reject(new Error('Malformed JSON.'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Route handlers are async — the shell only catches synchronous throws. */
|
||||
function guard(fn) {
|
||||
return (req, res) => {
|
||||
Promise.resolve(fn(req, res)).catch((err) => {
|
||||
try {
|
||||
sendJson(res, err && err.status ? err.status : 500, { error: err?.message || String(err) });
|
||||
} catch { /* the client is already gone */ }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where ProdDash keeps per-machine state, resolved the way the shell does.
|
||||
* Never the checkout: the repo folder is replaced on update.
|
||||
*/
|
||||
function resolveDataDir() {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||||
if (fromEnv) return path.resolve(root, fromEnv);
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(home, 'Library', 'Application Support', 'ProdDash');
|
||||
}
|
||||
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
||||
}
|
||||
|
||||
const MARKS_FILE = path.join(resolveDataDir(), 'service-times-marks.json');
|
||||
|
||||
function markKey(campus, service) {
|
||||
return `${campus}::${service}`;
|
||||
}
|
||||
|
||||
/* ── admin config shaping ───────────────────────────────────────────── */
|
||||
|
||||
function siteBase(config) {
|
||||
const raw = String(config.siteUrl || '').trim().replace(/\/+$/, '');
|
||||
if (!raw) return '';
|
||||
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
}
|
||||
|
||||
function listSetting(value) {
|
||||
if (Array.isArray(value)) return value.map((v) => slugify(v)).filter(Boolean);
|
||||
if (typeof value === 'string') {
|
||||
return value.split(/[\n,]+/).map((v) => slugify(v)).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/* ── the website ────────────────────────────────────────────────────── */
|
||||
|
||||
async function getText(url) {
|
||||
const response = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = new Error(`${response.status} ${response.statusText || ''}`.trim());
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/** Every campus page the site publishes — the sitemap first, the index page as a fallback. */
|
||||
async function discover(base, log) {
|
||||
const sources = [];
|
||||
for (const target of ['/page-sitemap.xml', '/wp-sitemap.xml', '/locations/', '/']) {
|
||||
try {
|
||||
sources.push(await getText(base + target));
|
||||
if (target.includes('sitemap')) break; // a sitemap is the fuller list
|
||||
} catch (err) {
|
||||
log(`discovery: ${target} — ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (!sources.length) return [];
|
||||
return discoverCampusSlugs(...sources).slice(0, MAX_CAMPUSES);
|
||||
}
|
||||
|
||||
async function readCampus(base, slug) {
|
||||
try {
|
||||
const html = await getText(`${base}/locations/${slug}/`);
|
||||
return parseCampusPage(html, { slug });
|
||||
} catch (err) {
|
||||
return {
|
||||
slug,
|
||||
name: slugify(slug).split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||
services: [],
|
||||
error: `Page unreadable: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ── module ─────────────────────────────────────────────────────────── */
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const streams = new Set();
|
||||
const base = siteBase(config);
|
||||
|
||||
const chosen = listSetting(config.campuses);
|
||||
const broadcast = new Set(listSetting(config.broadcast));
|
||||
const { feeds, errors: feedErrors } = parseFeeds(config.feeds);
|
||||
const { campuses: manualCampuses, errors: manualErrors } = parseManualCampuses(config.manual);
|
||||
const retentionHours = Math.max(0, Number(config.retentionHours) || 0);
|
||||
const refreshMs = Math.max(MIN_REFRESH_MINUTES, Number(config.refreshMinutes) || 360) * 60000;
|
||||
|
||||
let marks = new Map();
|
||||
let marksWritable = true;
|
||||
let discovered = [];
|
||||
let siteCampuses = [];
|
||||
let reachable = false;
|
||||
let lastError = base ? '' : 'No website address configured';
|
||||
let lastFetched = 0;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let lastFrame = '';
|
||||
|
||||
/* ── marks: load, save, expire ──────────────────────────────────── */
|
||||
|
||||
function loadMarks() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(MARKS_FILE, 'utf8'));
|
||||
for (const [key, value] of Object.entries(saved?.marks || {})) {
|
||||
if (value && typeof value === 'object') marks.set(key, value);
|
||||
}
|
||||
log(`${marks.size} mark(s) restored`);
|
||||
} catch {
|
||||
/* no marks yet — the usual case */
|
||||
}
|
||||
}
|
||||
|
||||
function saveMarks() {
|
||||
const payload = { marks: Object.fromEntries(marks), savedAt: Date.now() };
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(MARKS_FILE), { recursive: true });
|
||||
const tmp = `${MARKS_FILE}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n');
|
||||
fs.renameSync(tmp, MARKS_FILE);
|
||||
marksWritable = true;
|
||||
} catch (err) {
|
||||
// Never fall back to writing inside the checkout: the marks stay in
|
||||
// memory and health() says they will not survive a restart.
|
||||
if (marksWritable) log(`marks cannot be saved (${err.message}) — keeping them in memory only`);
|
||||
marksWritable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop marks older than the retention window. Returns true if any went. */
|
||||
function expireMarks() {
|
||||
if (!retentionHours) return false;
|
||||
const cutoff = Date.now() - retentionHours * 3600000;
|
||||
let dropped = false;
|
||||
for (const [key, mark] of marks) {
|
||||
if (!(Number(mark?.at) > cutoff)) {
|
||||
marks.delete(key);
|
||||
dropped = true;
|
||||
}
|
||||
}
|
||||
if (dropped) saveMarks();
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/* ── the view every tile draws ──────────────────────────────────── */
|
||||
|
||||
function campusList() {
|
||||
const wanted = chosen.length
|
||||
? siteCampuses.filter((c) => chosen.includes(c.slug))
|
||||
: siteCampuses;
|
||||
const merged = assignRoles(mergeCampuses(wanted, manualCampuses), { broadcast, feeds });
|
||||
|
||||
// Marks ride along on the service they belong to.
|
||||
for (const campus of merged) {
|
||||
campus.services = campus.services.map((service) => {
|
||||
const mark = marks.get(markKey(campus.slug, service.id)) || null;
|
||||
return { ...service, mark };
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
configured: Boolean(base),
|
||||
reachable,
|
||||
lastError,
|
||||
lastFetched,
|
||||
siteUrl: base,
|
||||
refreshMinutes: refreshMs / 60000,
|
||||
retentionHours,
|
||||
marksWritable,
|
||||
campuses: campusList(),
|
||||
discovered,
|
||||
manualErrors,
|
||||
feedErrors,
|
||||
now: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastState({ force = false } = {}) {
|
||||
if (!streams.size) return;
|
||||
const view = snapshot();
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.stringify(view);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// `now` changes every call; compare without it so idle tiles stay idle.
|
||||
const compare = frame.replace(/,"now":\d+/, '');
|
||||
if (!force && compare === lastFrame) return;
|
||||
lastFrame = compare;
|
||||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the refresh loop ───────────────────────────────────────────── */
|
||||
|
||||
async function refresh() {
|
||||
if (stopped) return;
|
||||
if (!base) {
|
||||
reachable = false;
|
||||
lastError = 'No website address configured';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const found = await discover(base, log);
|
||||
if (found.length) discovered = found;
|
||||
const slugs = chosen.length ? chosen : discovered;
|
||||
if (!slugs.length) throw new Error('No campus pages found on the site');
|
||||
|
||||
const read = await Promise.all(slugs.slice(0, MAX_CAMPUSES).map((slug) => readCampus(base, slug)));
|
||||
const usable = read.filter((campus) => campus.services.length);
|
||||
|
||||
// A total failure keeps the last good times on screen — a site outage
|
||||
// must not blank the board in the middle of a service.
|
||||
if (!usable.length && siteCampuses.length) {
|
||||
throw new Error(read[0]?.error || 'No service times found on the site');
|
||||
}
|
||||
siteCampuses = read;
|
||||
reachable = true;
|
||||
lastFetched = Date.now();
|
||||
const broken = read.filter((campus) => campus.error);
|
||||
lastError = broken.length ? `${broken.length} campus page(s) unreadable: ${broken[0].error}` : '';
|
||||
log(`read ${usable.length}/${read.length} campus page(s) from ${base}`);
|
||||
} catch (err) {
|
||||
reachable = false;
|
||||
lastError = err?.message || String(err);
|
||||
log(`refresh failed: ${lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(delay) {
|
||||
clearTimeout(timer);
|
||||
if (stopped) return;
|
||||
timer = setTimeout(async () => {
|
||||
await refresh();
|
||||
expireMarks();
|
||||
broadcastState();
|
||||
schedule(reachable ? refreshMs : RETRY_MS);
|
||||
}, delay);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
loadMarks();
|
||||
expireMarks();
|
||||
if (base) {
|
||||
log(`reading service times from ${base} every ${Math.round(refreshMs / 60000)} min`);
|
||||
} else {
|
||||
log('no website address configured — set one in /admin');
|
||||
}
|
||||
// First read immediately, then on the configured cadence.
|
||||
schedule(0);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
expireMarks();
|
||||
broadcastState();
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
current = {
|
||||
streams,
|
||||
snapshot,
|
||||
broadcastState,
|
||||
refreshNow: async () => {
|
||||
await refresh();
|
||||
broadcastState({ force: true });
|
||||
},
|
||||
setMark(campusSlug, serviceId, patch) {
|
||||
const key = markKey(slugify(campusSlug), String(serviceId || ''));
|
||||
const status = MARK_STATUSES.has(patch.status) ? patch.status : 'ok';
|
||||
if (status === 'ok' && !patch.note && !patch.source) {
|
||||
marks.delete(key);
|
||||
} else {
|
||||
marks.set(key, {
|
||||
status,
|
||||
note: String(patch.note || '').slice(0, NOTE_LIMIT),
|
||||
source: String(patch.source || '').slice(0, 120),
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
saveMarks();
|
||||
broadcastState({ force: true });
|
||||
},
|
||||
clearMarks() {
|
||||
const had = marks.size;
|
||||
marks.clear();
|
||||
saveMarks();
|
||||
broadcastState({ force: true });
|
||||
return had;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
clearInterval(heartbeat);
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!base) return { status: 'error', message: 'No website address configured' };
|
||||
const notes = [];
|
||||
if (!marksWritable) notes.push('marks are memory-only — the data directory is not writable');
|
||||
if (manualErrors.length) notes.push(manualErrors[0]);
|
||||
if (feedErrors.length) notes.push(feedErrors[0]);
|
||||
const superseded = campusList().filter((c) => c.supersededManual).map((c) => c.name);
|
||||
if (superseded.length) {
|
||||
notes.push(`the website now publishes ${superseded.join(', ')} — the manual line(s) can be removed`);
|
||||
}
|
||||
const suffix = notes.length ? ` — ${notes.join('; ')}` : '';
|
||||
if (!reachable) {
|
||||
return { status: 'error', message: `${lastError || 'Website unreachable'}${suffix}` };
|
||||
}
|
||||
const count = campusList().reduce((sum, c) => sum + c.services.length, 0);
|
||||
const warn = lastError || notes.length;
|
||||
return {
|
||||
status: warn ? 'connecting' : 'ok',
|
||||
message: `${count} service(s) across ${campusList().length} campus(es)${lastError ? ` — ${lastError}` : ''}${suffix}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The board, plus one entry per campus. A campus booth wants its own times
|
||||
* and the service it is taking resi from — not the whole network — so those
|
||||
* entries fix the campus and drop the campus picker from their gear.
|
||||
*/
|
||||
tiles() {
|
||||
const board = {
|
||||
id: 'board',
|
||||
name: 'Service Times',
|
||||
description: 'Every campus, its services, and what is marked for replay or unusable',
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 3, h: 3 },
|
||||
settings: {},
|
||||
};
|
||||
const campuses = current ? current.snapshot().campuses : [];
|
||||
return [
|
||||
board,
|
||||
...campuses.map((campus) => ({
|
||||
id: campus.slug,
|
||||
name: campus.name,
|
||||
description: `${campus.name} service times`,
|
||||
defaultSize: { w: 3, h: 4 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
settings: { campuses: [campus.slug] },
|
||||
instanceSchema: {
|
||||
showDay: { type: 'boolean', label: 'Day', default: true, group: 'Show' },
|
||||
showRole: { type: 'boolean', label: 'Role', default: true, group: 'Show' },
|
||||
showSource: { type: 'boolean', label: 'Resi source', default: true, group: 'Show' },
|
||||
showDependents: { type: 'boolean', label: 'Campuses fed', default: true, group: 'Show' },
|
||||
showNotes: { type: 'boolean', label: 'Notes', default: true, group: 'Show' },
|
||||
showUpdated: { type: 'boolean', label: 'Updated line', default: false, group: 'Show' },
|
||||
},
|
||||
instanceGroups: { Show: { collapsed: true, columns: 2 } },
|
||||
})),
|
||||
];
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
sendJson(res, 200, { state: current ? current.snapshot() : null });
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.snapshot() : null)}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
const { streams } = current;
|
||||
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
||||
// not queue frames forever; keepalive probes make the OS notice.
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
streams.add(res);
|
||||
req.on('close', () => streams.delete(res));
|
||||
},
|
||||
|
||||
'POST /mark': guard(async (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||
const body = await readJsonBody(req);
|
||||
const campus = String(body.campus || '').trim();
|
||||
const service = String(body.service || '').trim();
|
||||
if (!campus || !service) return sendJson(res, 400, { error: 'campus and service are required' });
|
||||
current.setMark(campus, service, {
|
||||
status: body.status,
|
||||
note: body.note,
|
||||
source: body.source,
|
||||
});
|
||||
sendJson(res, 200, { ok: true });
|
||||
}),
|
||||
|
||||
'POST /clear': guard(async (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||
sendJson(res, 200, { ok: true, cleared: current.clearMarks() });
|
||||
}),
|
||||
|
||||
'POST /refresh': guard(async (req, res) => {
|
||||
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
||||
await current.refreshNow();
|
||||
sendJson(res, 200, { ok: true, state: current.snapshot() });
|
||||
}),
|
||||
|
||||
'GET /campuses': (req, res) => {
|
||||
const campuses = current ? current.snapshot().campuses : [];
|
||||
sendJson(res, 200, {
|
||||
options: campuses.map((campus) => ({ value: campus.slug, label: campus.name })),
|
||||
});
|
||||
},
|
||||
|
||||
'GET /discovered': (req, res) => {
|
||||
const state = current ? current.snapshot() : null;
|
||||
const names = new Map((state?.campuses || []).map((c) => [c.slug, c.name]));
|
||||
const slugs = state?.discovered?.length ? state.discovered : [...names.keys()];
|
||||
sendJson(res, 200, {
|
||||
options: slugs.map((slug) => ({
|
||||
value: slug,
|
||||
label: names.get(slug)
|
||||
|| slug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||
})),
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user