Photos. Sync fell back to the Planning Center headshot whenever two
library photos matched a person equally ("Mike" and "mike"), and had no
way to know "Rose" is Rosaliz — so the booth dragged those in by hand
every week. Now:
- Several equally good library photos is not a reason to use PCO's; the
first of them is used. PCO's headshot is only for someone with no photo.
- Dropping a library photo onto the synced person already in that spot
(same name, same first name, or a nickname sharing its first three
letters) makes it their photo: they keep their Planning Center name and
spot, and it is remembered (settings.json photoFor) for every later
Sync. Photos already dragged in by hand for this plan are learned the
same way on the next Sync.
- A synced person's library photo is read live, so replacing that photo
updates the board without a re-sync.
Dragging. The editor was one scrolling column, so a photo found far down
the list had to be carried back up to its spot, and every drop redrew the
editor back to the top. The spots are now pinned in their own pane above
the photos, each pane auto-scrolls when a drag nears its edge, both keep
their scroll position through redraws, and a search box filters the
photos by name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
810 lines
45 KiB
JavaScript
810 lines
45 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Band Lineup — server part.
|
||
*
|
||
* Owns the shared stage board and the uploaded-photo library, plus the one
|
||
* Planning Center connection. The board is server-side and global: whoever
|
||
* edits it (drag a library photo onto a slot, rename, clear) changes it for
|
||
* every Band Lineup tile on every screen, over SSE. Planning Center sync is an
|
||
* optional auto-fill — it only fills slots nobody has placed by hand. Browsers
|
||
* never talk to Planning Center directly.
|
||
*
|
||
* GET /state → { state } (board + library; instant paint)
|
||
* GET /stream → SSE `state` events (change-detected, heartbeat)
|
||
* GET /library → { people } (the drag-in photo library)
|
||
* GET /service-types → { options } (admin "Service type" select)
|
||
* GET /plans → { options } (admin "Plan" select)
|
||
* GET /people → { options } (gear "Force birthday" select)
|
||
* GET /photo/<file> → image bytes (library + cached PCO photos)
|
||
* POST /library/<id> → rename / set birthday / replace photo (raw image body; ?name=&birthday=)
|
||
* POST /library/<id>/delete→ remove from the library and any slot
|
||
* POST /assign → ?slot=&personId= place a library person in a slot
|
||
* POST /move → ?from=&to= move or swap one slot's occupant
|
||
* POST /slot/<index>/name → ?name= rename the occupant (or make a name-only spot)
|
||
* POST /slot/<index>/clear → empty the slot
|
||
* POST /choir → ?on=1|0 toggle the extra CHOIR slot (shared)
|
||
* POST /sync → run a Planning Center + photo-folder sync now
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const crypto = require('crypto');
|
||
|
||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson } = require('./matching');
|
||
|
||
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||
const SERVICES = '/services/v2';
|
||
const PEOPLE = '/people/v2';
|
||
|
||
const REQUEST_TIMEOUT_MS = 12000;
|
||
const PHOTO_TIMEOUT_MS = 15000;
|
||
const MIN_POLL_S = 10;
|
||
const RETRY_MS = 15000;
|
||
const HEARTBEAT_MS = 15000;
|
||
const MODULE_ID = 'band-lineup';
|
||
const MAX_UPLOAD = 20 * 1024 * 1024;
|
||
|
||
let current = null; // set by init(), read by routes; rebuilt together on remount
|
||
|
||
/* ── data directory (library + board + photo cache live here, never in the repo) ── */
|
||
|
||
function resolveDataDir() {
|
||
const home = os.homedir();
|
||
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
||
const root = path.join(__dirname, '..', '..');
|
||
if (fromEnv) return path.resolve(root, fromEnv);
|
||
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');
|
||
}
|
||
|
||
/* ── small helpers ─────────────────────────────────────────────────── */
|
||
|
||
function lines(text) { return String(text || '').split('\n').map((s) => s.trim()); }
|
||
function nonEmptyLines(text) { return lines(text).filter(Boolean); }
|
||
function localYmd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
|
||
function birthdayMmdd(raw) {
|
||
const s = String(raw || '').trim();
|
||
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/);
|
||
if (full) return `${full[1]}-${full[2]}`;
|
||
return /^\d{2}-\d{2}$/.test(s) ? s : '';
|
||
}
|
||
function birthdayWeekSet(now = new Date()) {
|
||
const daysSinceWed = (now.getDay() - 3 + 7) % 7;
|
||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
|
||
const set = new Set();
|
||
for (let i = 0; i < 7; i += 1) {
|
||
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
|
||
}
|
||
return set;
|
||
}
|
||
function extFromContentType(ct) {
|
||
const t = String(ct || '').toLowerCase();
|
||
if (t.includes('png')) return 'png';
|
||
if (t.includes('webp')) return 'webp';
|
||
if (t.includes('gif')) return 'gif';
|
||
return 'jpg';
|
||
}
|
||
function contentTypeForFile(file) {
|
||
const e = path.extname(file).toLowerCase();
|
||
if (e === '.png') return 'image/png';
|
||
if (e === '.webp') return 'image/webp';
|
||
if (e === '.gif') return 'image/gif';
|
||
return 'image/jpeg';
|
||
}
|
||
function json(res, status, body) {
|
||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||
res.end(JSON.stringify(body));
|
||
}
|
||
function readJsonFile(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) || fallback; } catch { return fallback; } }
|
||
function newId() { return crypto.randomBytes(6).toString('hex'); }
|
||
function photoUrlFor(file) { return file ? `/api/modules/${MODULE_ID}/photo/${encodeURIComponent(file)}` : ''; }
|
||
|
||
/* ── Planning Center client ────────────────────────────────────────── */
|
||
|
||
function createPcoClient(config) {
|
||
const token = String(config.accessToken || '').trim();
|
||
const appId = String(config.appId || '').trim();
|
||
const secret = String(config.secret || '');
|
||
let auth = '';
|
||
if (token) auth = `Bearer ${token}`;
|
||
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
|
||
const configured = Boolean(auth);
|
||
|
||
async function get(target, params) {
|
||
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
|
||
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
|
||
let response;
|
||
try {
|
||
response = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||
} catch (err) {
|
||
const e = new Error(`Planning Center unreachable: ${err?.message || err}`); e.network = true; throw e;
|
||
}
|
||
if (!response.ok) { const e = new Error(`Planning Center ${response.status}`); e.status = response.status; throw e; }
|
||
const text = await response.text();
|
||
return text ? JSON.parse(text) : {};
|
||
}
|
||
async function getAll(target, params) {
|
||
const data = []; const included = []; let next = null;
|
||
let query = { per_page: 100, ...(params || {}) }; let guard = 0;
|
||
do {
|
||
const body = next ? await get(next) : await get(target, query);
|
||
if (Array.isArray(body.data)) data.push(...body.data);
|
||
if (Array.isArray(body.included)) included.push(...body.included);
|
||
next = body?.links?.next || null; query = null; guard += 1;
|
||
} while (next && guard < 50);
|
||
return { data, included };
|
||
}
|
||
return { configured, get, getAll };
|
||
}
|
||
|
||
/* ── Google Drive (public "anyone with the link" folder, no API key) ── */
|
||
|
||
const DRIVE_TIMEOUT_MS = 30000;
|
||
const DRIVE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36';
|
||
|
||
/** Pull the folder id out of a Drive share link (or '' if it isn't a Drive folder link). */
|
||
function parseDriveFolderId(s) {
|
||
const str = String(s || '');
|
||
if (!/drive\.google\.com/i.test(str)) return '';
|
||
const m = /\/folders\/([A-Za-z0-9_-]{10,})/.exec(str) || /[?&]id=([A-Za-z0-9_-]{10,})/.exec(str);
|
||
return m ? m[1] : '';
|
||
}
|
||
|
||
/** List a public folder's files via Drive's embedded folder view — [{ id, name }]. */
|
||
async function driveList(folderId) {
|
||
const resp = await fetch(`https://drive.google.com/embeddedfolderview?id=${encodeURIComponent(folderId)}#list`, {
|
||
headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS),
|
||
});
|
||
if (!resp.ok) throw new Error(`Drive folder list ${resp.status}`);
|
||
const html = await resp.text();
|
||
const out = []; const seen = new Set();
|
||
const re = /id="entry-([A-Za-z0-9_-]{20,})"[\s\S]*?flip-entry-title[^>]*>([^<]+)</g;
|
||
let m;
|
||
while ((m = re.exec(html))) {
|
||
const id = m[1]; const name = m[2].trim();
|
||
if (id && !seen.has(id)) { seen.add(id); out.push({ id, name }); }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Download one public Drive file at full resolution (falls back to a large thumbnail). */
|
||
async function driveFetchImage(fileId) {
|
||
const base = 'https://drive.usercontent.google.com/download';
|
||
const opts = { headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS) };
|
||
let resp = await fetch(`${base}?id=${fileId}&export=download`, opts);
|
||
let ct = String(resp.headers.get('content-type') || '');
|
||
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
|
||
// Large-file virus-scan interstitial: grab the confirm token and retry.
|
||
if (ct.includes('text/html')) {
|
||
const html = await resp.text();
|
||
const conf = /name="confirm"\s+value="([^"]+)"/.exec(html) || /[?&]confirm=([0-9A-Za-z_-]+)/.exec(html);
|
||
const uuid = /name="uuid"\s+value="([^"]+)"/.exec(html);
|
||
if (conf) {
|
||
resp = await fetch(`${base}?id=${fileId}&export=download&confirm=${conf[1]}${uuid ? `&uuid=${uuid[1]}` : ''}`, opts);
|
||
ct = String(resp.headers.get('content-type') || '');
|
||
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
|
||
}
|
||
}
|
||
const th = await fetch(`https://drive.google.com/thumbnail?id=${fileId}&sz=w2000`, opts);
|
||
const tct = String(th.headers.get('content-type') || '');
|
||
if (th.ok && tct.startsWith('image/')) return { buf: Buffer.from(await th.arrayBuffer()), ext: extFromContentType(tct) };
|
||
throw new Error('not an image');
|
||
}
|
||
|
||
/* ── the module ────────────────────────────────────────────────────── */
|
||
|
||
module.exports = {
|
||
init({ config, log }) {
|
||
const streams = new Set();
|
||
const dataDir = path.join(resolveDataDir(), MODULE_ID);
|
||
const photoDir = path.join(dataDir, 'photos');
|
||
const libraryPath = path.join(dataDir, 'library.json');
|
||
const boardPath = path.join(dataDir, 'board.json');
|
||
try { fs.mkdirSync(photoDir, { recursive: true }); } catch (err) { log(`cannot create ${photoDir}: ${err?.message || err}`); }
|
||
|
||
const slots = nonEmptyLines(config.slots).length ? lines(config.slots) : [];
|
||
const teamKeywords = nonEmptyLines(config.teams).length ? nonEmptyLines(config.teams) : ['band', 'worship', 'vocal', 'production', 'communication'];
|
||
const columns = Math.max(1, Number(config.columns) || 7);
|
||
const serviceTypeId = String(config.serviceTypeId || '').trim();
|
||
const planIdCfg = String(config.planId || '').trim();
|
||
// Photos folder: the admin "Photos folder" field wins when set; otherwise it's whatever the
|
||
// tile's "Choose folder…" picker saved into module state. Admin can't host a native picker
|
||
// (the shell renders plain fields), so the picker lives in the tile as the no-typing option.
|
||
const settingsPath = path.join(dataDir, 'settings.json');
|
||
const settings = readJsonFile(settingsPath, {}) || {};
|
||
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
|
||
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
|
||
// The photo the booth picked for a Planning Center person (PCO person id → library id), learned
|
||
// when a library photo is dropped onto that person. Sync uses it before any name matching.
|
||
if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {};
|
||
function rememberPhoto(pcoPersonId, libId) {
|
||
if (!pcoPersonId || !libId || settings.photoFor[pcoPersonId] === libId) return;
|
||
settings.photoFor[pcoPersonId] = libId; saveSettings();
|
||
}
|
||
// Library photo for a scheduled person: their remembered pick, else the best name match.
|
||
function libraryPhotoFor(personId, name) {
|
||
const picked = settings.photoFor[personId];
|
||
if (picked && library[picked] && library[picked].file) return { id: picked, ...library[picked] };
|
||
const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })));
|
||
return m && m.file ? m : null;
|
||
}
|
||
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
|
||
|
||
const CHOIR_LABEL = 'CHOIR';
|
||
// The board's slots plus an extra CHOIR spot when the toggle is on. Its index is slots.length,
|
||
// so its assignment persists in board.json across toggles.
|
||
const effectiveSlots = () => (choirOn ? slots.concat([CHOIR_LABEL]) : slots);
|
||
|
||
const pco = createPcoClient(config);
|
||
|
||
// Persisted, shared editor state.
|
||
let library = readJsonFile(libraryPath, {}); // { id: { name, file, birthday } }
|
||
let board = readJsonFile(boardPath, {}); // { slotIndex: { personId, name } }
|
||
function saveLibrary() { try { fs.writeFileSync(libraryPath, JSON.stringify(library, null, 2)); } catch (err) { log(`save library failed: ${err?.message || err}`); } }
|
||
function saveBoard() { try { fs.writeFileSync(boardPath, JSON.stringify(board, null, 2)); } catch (err) { log(`save board failed: ${err?.message || err}`); } }
|
||
|
||
let latest = clearedState();
|
||
let lastFrame = '';
|
||
let celebrateTimer = null;
|
||
let drivePollTimer = null;
|
||
let watcher = null;
|
||
let watchDebounce = null;
|
||
let stopped = false;
|
||
let syncing = false;
|
||
|
||
function clearedState() {
|
||
return {
|
||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||
reachable: false, lastError: '', lastFetched: 0, columns,
|
||
plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right
|
||
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||
library: [], choir: choirOn,
|
||
};
|
||
}
|
||
|
||
function libraryList() {
|
||
return Object.entries(library)
|
||
.map(([id, v]) => ({ id, name: v.name || '', photo: photoUrlFor(v.file || ''), birthday: v.birthday || '' }))
|
||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||
}
|
||
|
||
function broadcast() {
|
||
if (!streams.size) return;
|
||
let frame; try { frame = JSON.stringify(latest); } catch { return; }
|
||
if (frame === lastFrame) return;
|
||
lastFrame = frame;
|
||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||
for (const res of streams) { try { res.write(payload); } catch { streams.delete(res); } }
|
||
}
|
||
|
||
async function listPlans(stId) {
|
||
const [future, past] = await Promise.all([
|
||
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'future', order: 'sort_date', per_page: 6 }),
|
||
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'past', order: '-sort_date', per_page: 2 }),
|
||
]);
|
||
const rows = [...(future.data || []), ...(past.data || [])];
|
||
return rows.map((rec) => ({
|
||
id: String(rec.id), sortDate: Date.parse(rec.attributes?.sort_date || '') || 0,
|
||
title: String(rec.attributes?.title || rec.attributes?.series_title || '').trim(),
|
||
dates: String(rec.attributes?.dates || rec.attributes?.short_dates || '').trim(),
|
||
}));
|
||
}
|
||
function pickPlan(plans) {
|
||
if (planIdCfg) return plans.find((p) => p.id === planIdCfg) || { id: planIdCfg, title: '', dates: '' };
|
||
const todayY = localYmd(new Date());
|
||
const today = plans.find((p) => p.sortDate && localYmd(new Date(p.sortDate)) === todayY);
|
||
if (today) return today;
|
||
const now = Date.now();
|
||
const upcoming = plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate);
|
||
return upcoming[0] || plans.sort((a, b) => b.sortDate - a.sortDate)[0] || null;
|
||
}
|
||
|
||
async function cachePcoPhoto(personId, url) {
|
||
if (!url) return '';
|
||
const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 8);
|
||
const existing = fs.existsSync(photoDir) ? fs.readdirSync(photoDir).find((f) => f.startsWith(`pco-${personId}-${hash}.`)) : null;
|
||
if (existing) return existing;
|
||
try {
|
||
const resp = await fetch(url, { signal: AbortSignal.timeout(PHOTO_TIMEOUT_MS) });
|
||
if (!resp.ok) return '';
|
||
const buf = Buffer.from(await resp.arrayBuffer());
|
||
if (!buf.length) return '';
|
||
const file = `pco-${personId}-${hash}.${extFromContentType(resp.headers.get('content-type'))}`;
|
||
fs.writeFileSync(path.join(photoDir, file), buf);
|
||
return file;
|
||
} catch { return ''; }
|
||
}
|
||
|
||
/**
|
||
* Import a folder of photos into the library and keep it in sync: new files are added
|
||
* (filename → name), changed files refreshed, and files removed from the folder dropped
|
||
* from the library (and any slot they filled). Manually-uploaded photos (no folderKey) are
|
||
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
|
||
*/
|
||
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
|
||
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
|
||
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
|
||
function scanFolder() {
|
||
if (!photoFolder) return false;
|
||
let names; try { names = fs.readdirSync(photoFolder); } catch { return false; } // folder gone/unreadable
|
||
const files = names.filter((n) => IMG_RE.test(n));
|
||
const present = new Set(files);
|
||
const byKey = new Map();
|
||
for (const [id, v] of Object.entries(library)) if (v.folderKey) byKey.set(v.folderKey, id);
|
||
let changed = false;
|
||
for (const f of files) {
|
||
let mtime = 0; try { mtime = fs.statSync(path.join(photoFolder, f)).mtimeMs; } catch { continue; }
|
||
const id = byKey.get(f);
|
||
try {
|
||
if (id) {
|
||
const v = library[id];
|
||
if (v.mtime === mtime && v.file) continue; // unchanged
|
||
const dest = `lib-${id}.${extOf(f)}`;
|
||
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
|
||
if (v.file && v.file !== dest) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||
v.file = dest; v.mtime = mtime; changed = true; // keep any in-app rename/birthday
|
||
} else {
|
||
const nid = newId();
|
||
const dest = `lib-${nid}.${extOf(f)}`;
|
||
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
|
||
library[nid] = { name: cleanNameFromFile(f), file: dest, birthday: '', folderKey: f, mtime };
|
||
changed = true;
|
||
}
|
||
} catch (err) { log(`folder import of ${f} failed: ${err?.message || err}`); }
|
||
}
|
||
for (const [id, v] of Object.entries(library)) {
|
||
if (v.folderKey && !present.has(v.folderKey)) {
|
||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||
delete library[id];
|
||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
|
||
changed = true;
|
||
}
|
||
}
|
||
// De-duplicate: a hand-uploaded photo that shares a name with a folder photo is dropped
|
||
// (the folder wins), and any slot it filled repoints to the folder photo. Fixes the case
|
||
// where someone uploaded people by hand and then pointed at a folder of the same people.
|
||
const folderByName = new Map();
|
||
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.name) folderByName.set(normName(v.name), id);
|
||
for (const [id, v] of Object.entries(library)) {
|
||
if (v.folderKey || !v.name) continue;
|
||
const keepId = folderByName.get(normName(v.name));
|
||
if (!keepId) continue;
|
||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
|
||
delete library[id];
|
||
changed = true;
|
||
}
|
||
if (changed) { saveLibrary(); saveBoard(); }
|
||
return changed;
|
||
}
|
||
|
||
// Import a public Google Drive folder into the library (file name → name), keeping it in sync.
|
||
// Keyed by Drive file id (folderKey "drive:<id>"). On a list failure we keep the last-good library.
|
||
async function scanDrive(folderId) {
|
||
let files;
|
||
try { files = await driveList(folderId); } catch (err) { log(`Drive list failed: ${err?.message || err}`); return false; }
|
||
files = files.filter((f) => IMG_RE.test(f.name));
|
||
const present = new Set(files.map((f) => `drive:${f.id}`));
|
||
const have = new Set();
|
||
for (const v of Object.values(library)) if (v.folderKey && v.folderKey.startsWith('drive:')) have.add(v.folderKey);
|
||
let changed = false;
|
||
for (const f of files) {
|
||
const key = `drive:${f.id}`;
|
||
if (have.has(key)) continue; // already imported (by Drive file id)
|
||
let img;
|
||
try { img = await driveFetchImage(f.id); } catch (err) { log(`Drive image ${f.name} failed: ${err?.message || err}`); continue; }
|
||
const nid = newId();
|
||
const dest = `lib-${nid}.${img.ext}`;
|
||
try { fs.writeFileSync(path.join(photoDir, dest), img.buf); library[nid] = { name: cleanNameFromFile(f.name), file: dest, birthday: '', folderKey: key }; changed = true; }
|
||
catch (err) { log(`write ${dest} failed: ${err?.message || err}`); }
|
||
}
|
||
for (const [id, v] of Object.entries(library)) {
|
||
if (v.folderKey && v.folderKey.startsWith('drive:') && !present.has(v.folderKey)) {
|
||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||
delete library[id];
|
||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
|
||
changed = true;
|
||
}
|
||
}
|
||
// Drive photo wins over a same-named hand-upload (repoint any slot to it).
|
||
const driveByName = new Map();
|
||
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.folderKey.startsWith('drive:') && v.name) driveByName.set(normName(v.name), id);
|
||
for (const [id, v] of Object.entries(library)) {
|
||
if ((v.folderKey && v.folderKey.startsWith('drive:')) || !v.name) continue;
|
||
const keepId = driveByName.get(normName(v.name));
|
||
if (!keepId) continue;
|
||
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
|
||
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
|
||
delete library[id]; changed = true;
|
||
}
|
||
if (changed) { saveLibrary(); saveBoard(); }
|
||
return changed;
|
||
}
|
||
|
||
// Import the configured photo source — a Drive folder link, or a local folder path.
|
||
async function syncPhotos() {
|
||
const fid = parseDriveFolderId(photoFolder);
|
||
if (fid) return scanDrive(fid);
|
||
if (photoFolder) return scanFolder();
|
||
return false;
|
||
}
|
||
|
||
function startWatch() {
|
||
if (parseDriveFolderId(photoFolder)) return; // Drive folders are polled, not fs.watched
|
||
if (!photoFolder) return;
|
||
try {
|
||
watcher = fs.watch(photoFolder, { persistent: false }, () => {
|
||
clearTimeout(watchDebounce);
|
||
watchDebounce = setTimeout(() => { if (!stopped && scanFolder()) rebuildLocal(); }, 800);
|
||
});
|
||
log(`watching photo folder ${photoFolder}`);
|
||
} catch (err) { log(`cannot watch photo folder: ${err?.message || err}`); }
|
||
}
|
||
function setChoir(on) { choirOn = Boolean(on); settings.choir = choirOn; saveSettings(); rebuildLocal(); }
|
||
|
||
// Remove cached PCO photos no longer referenced. Library files (lib-*) are never pruned here.
|
||
function prunePcoPhotos(keep) {
|
||
let files; try { files = fs.readdirSync(photoDir); } catch { return; }
|
||
for (const f of files) { if (f.startsWith('pco-') && !keep.has(f)) { try { fs.unlinkSync(path.join(photoDir, f)); } catch { /* ignore */ } } }
|
||
}
|
||
|
||
const normName = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||
|
||
// Resolve one board slot to a display person (or null). The board is the source of truth —
|
||
// there is no live PCO overlay, so clearing a slot stays cleared until the next Sync. A PCO
|
||
// entry prefers a same-named library photo, so uploading a photo updates the board without a re-sync.
|
||
function resolveSlot(i, weekSet, keep) {
|
||
const e = board[String(i)];
|
||
if (!e) return null;
|
||
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||
if (e.pco) {
|
||
// A synced person. Their library photo (if any) is read live, so replacing that photo
|
||
// updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
|
||
manual = false; personId = e.pco.personId || '';
|
||
name = String(e.pco.name || '').trim();
|
||
const lib = e.pco.libId ? library[e.pco.libId] : null;
|
||
file = (lib && lib.file) || e.pco.file || '';
|
||
birthday = e.pco.birthday || (lib && lib.birthday) || '';
|
||
} else if (e.personId) {
|
||
const lib = library[e.personId];
|
||
if (!lib) return null; // its library photo was deleted → slot is empty
|
||
personId = e.personId;
|
||
name = String(e.name || lib.name || '').trim();
|
||
file = lib.file || ''; birthday = lib.birthday || '';
|
||
} else if (e.name) {
|
||
name = String(e.name).trim();
|
||
} else return null;
|
||
if (file) keep.add(file);
|
||
return { name, photo: photoUrlFor(file), birthday, celebrating: Boolean(birthday) && weekSet.has(birthday), manual, personId };
|
||
}
|
||
|
||
// Repaint the display from the board (no Planning Center calls). Cheap — used after every edit
|
||
// and on a slow timer so a birthday week rolling over lights up on its own.
|
||
function renderBoard() {
|
||
const weekSet = birthdayWeekSet();
|
||
const keep = new Set();
|
||
const outSlots = effectiveSlots().map((label, i) => ({ index: i, label, person: resolveSlot(i, weekSet, keep) }));
|
||
for (const v of Object.values(library)) if (v.file) keep.add(v.file);
|
||
prunePcoPhotos(keep);
|
||
latest = {
|
||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||
reachable: latest.reachable, lastError: latest.lastError || '', lastFetched: latest.lastFetched || 0,
|
||
columns, plan: latest.plan || null, slots: outSlots, library: libraryList(), choir: choirOn,
|
||
};
|
||
}
|
||
|
||
function rebuildLocal() { renderBoard(); broadcast(); }
|
||
|
||
// Pull the current plan from Planning Center and write matched people INTO the board. Fills only
|
||
// slots nobody placed by hand; drops PCO entries no longer scheduled; leaves manual placements be.
|
||
async function syncNow() {
|
||
if (syncing) return;
|
||
syncing = true;
|
||
try {
|
||
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||
if (pco.configured && serviceTypeId) {
|
||
const plan = pickPlan(await listPlans(serviceTypeId));
|
||
latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null;
|
||
if (plan) { settings.lastPlan = latest.plan; saveSettings(); }
|
||
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||
else {
|
||
|
||
const tm = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(plan.id)}/team_members`, { include: 'person,team' });
|
||
const byKey = new Map((tm.included || []).map((e) => [`${e.type}:${e.id}`, e]));
|
||
const members = []; const seen = new Set();
|
||
for (const m of tm.data || []) {
|
||
const status = String(m.attributes?.status || '').trim();
|
||
if (status === 'D' || /^declined$/i.test(status)) continue;
|
||
const personId = String(m.relationships?.person?.data?.id || '');
|
||
const team = byKey.get(`Team:${String(m.relationships?.team?.data?.id || '')}`);
|
||
if (!isAllowedTeamName(String(team?.attributes?.name || ''), teamKeywords)) continue;
|
||
const person = byKey.get(`Person:${personId}`);
|
||
members.push({
|
||
personId,
|
||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||
label: String(m.attributes?.team_position_name || '').trim(),
|
||
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
|
||
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||
});
|
||
if (personId) seen.add(personId);
|
||
}
|
||
const bdays = new Map();
|
||
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
|
||
for (const m of members) m.birthday = bdays.get(m.personId) || '';
|
||
|
||
// A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled
|
||
// ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their
|
||
// Planning Center name, kept in that spot.
|
||
for (const [i, e] of Object.entries(board)) {
|
||
if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue;
|
||
const libName = library[e.personId].name;
|
||
const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName));
|
||
if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement
|
||
const m = who[0];
|
||
rememberPhoto(m.personId, e.personId);
|
||
board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId }, plan: e.plan };
|
||
}
|
||
|
||
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
|
||
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
|
||
// from before placements were stamped) is stale: Sync replaces it like any synced slot.
|
||
const eff = effectiveSlots();
|
||
const planId = String(plan.id);
|
||
const openIdx = []; const manualNames = new Set();
|
||
for (let i = 0; i < eff.length; i += 1) {
|
||
const e = board[String(i)];
|
||
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
|
||
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||
if (nm) manualNames.add(normName(nm));
|
||
} else openIdx.push(i);
|
||
}
|
||
const pool = members.filter((m) => !manualNames.has(normName(m.name)));
|
||
const assigned = assignMembersToSlots(openIdx.map((i) => eff[i]), pool);
|
||
|
||
for (let k = 0; k < openIdx.length; k += 1) {
|
||
const i = openIdx[k]; const m = assigned[k];
|
||
if (m) {
|
||
const lib = libraryPhotoFor(m.personId, m.name); // the booth's own photos win over the PCO headshot
|
||
const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||
board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId } };
|
||
} else if (board[String(i)]) {
|
||
delete board[String(i)]; // synced or stale, and no longer on the plan
|
||
}
|
||
}
|
||
saveBoard();
|
||
latest.reachable = true; latest.lastError = ''; latest.lastFetched = Date.now();
|
||
log(`synced ${members.length} scheduled from ${plan.dates || plan.id}`);
|
||
} // end else (plan found)
|
||
} // end if (Planning Center configured)
|
||
} catch (err) {
|
||
latest.reachable = false; latest.lastError = err?.message || String(err);
|
||
log(`sync failed: ${err?.message || err}`);
|
||
} finally {
|
||
syncing = false;
|
||
renderBoard(); broadcast();
|
||
}
|
||
}
|
||
|
||
const heartbeat = setInterval(() => { for (const res of streams) { try { res.write(': ping\n\n'); } catch { streams.delete(res); } } }, HEARTBEAT_MS);
|
||
heartbeat.unref?.();
|
||
// Repaint on a slow timer so a birthday week (Wed→Tue) rolling over lights up unattended.
|
||
celebrateTimer = setInterval(() => { if (!stopped) rebuildLocal(); }, 15 * 60 * 1000);
|
||
celebrateTimer.unref?.();
|
||
// A Drive folder can't be fs.watched — re-import it on a slow timer so photos added/removed
|
||
// in Drive show up without a manual Sync.
|
||
drivePollTimer = setInterval(() => {
|
||
if (stopped || !parseDriveFolderId(photoFolder)) return;
|
||
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch(() => {});
|
||
}, 5 * 60 * 1000);
|
||
drivePollTimer.unref?.();
|
||
|
||
if (pco.configured && serviceTypeId) log('ready — press Sync to load the plan from Planning Center');
|
||
else log('manual board (add Planning Center credentials + a service type in /admin to enable Sync)');
|
||
rebuildLocal(); // paint the saved board immediately
|
||
startWatch(); // local folders keep in sync via fs.watch (Drive folders are polled)
|
||
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch((err) => log(`initial photo import failed: ${err?.message || err}`));
|
||
|
||
current = {
|
||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||
planId: () => (latest.plan && latest.plan.id) || '',
|
||
rememberPhoto,
|
||
photoUrlFor, libraryList,
|
||
setChoir, choirOn: () => choirOn,
|
||
};
|
||
|
||
return {
|
||
stop() {
|
||
stopped = true; if (celebrateTimer) clearInterval(celebrateTimer); if (drivePollTimer) clearInterval(drivePollTimer); clearInterval(heartbeat);
|
||
if (watcher) { try { watcher.close(); } catch { /* gone */ } watcher = null; }
|
||
if (watchDebounce) clearTimeout(watchDebounce);
|
||
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
|
||
streams.clear(); if (current && current.streams === streams) current = null;
|
||
},
|
||
health() {
|
||
if (!pco.configured || !serviceTypeId) return { status: 'ok', message: 'Manual board (add Planning Center to enable Sync)' };
|
||
if (latest.lastError) return { status: 'error', message: latest.lastError };
|
||
if (latest.reachable) return { status: 'ok', message: latest.plan?.dates ? `Synced: ${latest.plan.dates}` : 'Synced' };
|
||
return { status: 'ok', message: 'Ready — press Sync to load the plan' };
|
||
},
|
||
};
|
||
},
|
||
|
||
routes({ log }) {
|
||
// Read an image request body (raw bytes) with a size cap.
|
||
function readBody(req, cb) {
|
||
const chunks = []; let size = 0; let bad = false;
|
||
req.on('data', (c) => { size += c.length; if (size > MAX_UPLOAD) { bad = true; req.destroy(); } else chunks.push(c); });
|
||
req.on('end', () => cb(bad ? null : Buffer.concat(chunks)));
|
||
req.on('error', () => cb(null));
|
||
}
|
||
|
||
return {
|
||
'GET /state': (req, res) => json(res, 200, { state: current ? current.getLatest() : 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.getLatest() : null)}\n\n`);
|
||
if (!current) return void res.end();
|
||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||
current.streams.add(res);
|
||
req.on('close', () => current && current.streams.delete(res));
|
||
},
|
||
|
||
'GET /library': (req, res) => json(res, 200, { people: current ? current.libraryList() : [] }),
|
||
|
||
// Update a library entry: ?name=&birthday= and/or a replacement image body. /library/<id>/delete removes it.
|
||
'POST /library/*': (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
|
||
const del = /\/delete$/.test(rest);
|
||
const id = rest.replace(/\/delete$/, '').trim();
|
||
const entry = current.library[id];
|
||
if (!entry) return json(res, 404, { error: 'No such library photo.' });
|
||
if (del) {
|
||
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
|
||
delete current.library[id];
|
||
for (const [slot, v] of Object.entries(current.board)) if (v && v.personId === id) delete current.board[slot];
|
||
current.saveLibrary(); current.saveBoard(); current.rebuildLocal();
|
||
return json(res, 200, { ok: true });
|
||
}
|
||
const q = new URLSearchParams(req.search || '');
|
||
readBody(req, (buf) => {
|
||
if (q.has('name')) entry.name = String(q.get('name') || '').trim();
|
||
if (q.has('birthday')) entry.birthday = birthdayMmdd(q.get('birthday'));
|
||
if (buf && buf.length) {
|
||
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
|
||
const file = `lib-${id}.${extFromContentType(req.headers['content-type'])}`;
|
||
try { fs.writeFileSync(path.join(current.photoDir, file), buf); entry.file = file; } catch (err) { return json(res, 500, { error: `write failed: ${err?.message || err}` }); }
|
||
}
|
||
current.saveLibrary(); current.rebuildLocal();
|
||
json(res, 200, { ok: true });
|
||
});
|
||
},
|
||
|
||
// Toggle the extra CHOIR slot on/off (?on=1|0, or omit to flip). Shared across all screens.
|
||
'POST /choir': (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
const p = new URLSearchParams(req.search || '');
|
||
const on = p.has('on') ? (p.get('on') === '1' || p.get('on') === 'true') : !current.choirOn();
|
||
current.setChoir(on);
|
||
json(res, 200, { ok: true, choir: on });
|
||
},
|
||
|
||
// Move (or swap) one slot's occupant to another slot: ?from=<i>&to=<j>.
|
||
'POST /move': (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
const p = new URLSearchParams(req.search || '');
|
||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||
const a = current.board[from]; const b = current.board[to];
|
||
// Moving a spot is a hand placement for this plan: Sync must not move it back.
|
||
const plan = current.planId();
|
||
if (a) a.plan = plan;
|
||
if (b) b.plan = plan;
|
||
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||
current.saveBoard(); current.rebuildLocal();
|
||
json(res, 200, { ok: true });
|
||
},
|
||
|
||
// Place a library person in a slot: ?slot=<index>&personId=<id> (empty personId clears the slot).
|
||
'POST /assign': (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
const q = new URLSearchParams(req.search || '');
|
||
const slot = String(q.get('slot') || '').trim();
|
||
const personId = String(q.get('personId') || '').trim();
|
||
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
||
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
||
const here = current.board[slot];
|
||
if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) {
|
||
// A photo of the person already in this spot: it's their photo now — this week and every
|
||
// Sync after. They keep their Planning Center name and their spot.
|
||
current.rememberPhoto(here.pco.personId, personId);
|
||
current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() };
|
||
} else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
|
||
else delete current.board[slot];
|
||
current.saveBoard(); current.rebuildLocal();
|
||
json(res, 200, { ok: true });
|
||
},
|
||
|
||
// Rename a slot's occupant, or make a name-only spot (no photo). /slot/<index>/clear empties it.
|
||
'POST /slot/*': (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
|
||
const clearing = /\/clear$/.test(rest);
|
||
const index = rest.replace(/\/(clear|name)$/, '').trim();
|
||
if (!/^\d+$/.test(index)) return json(res, 400, { error: 'Bad slot.' });
|
||
if (clearing) { delete current.board[index]; current.saveBoard(); current.rebuildLocal(); return json(res, 200, { ok: true }); }
|
||
const q = new URLSearchParams(req.search || '');
|
||
const name = String(q.get('name') || '').trim();
|
||
const existing = current.board[index] || { personId: '', name: '' };
|
||
existing.name = name;
|
||
existing.plan = current.planId();
|
||
if (!existing.personId && !name) delete current.board[index];
|
||
else current.board[index] = existing;
|
||
current.saveBoard(); current.rebuildLocal();
|
||
json(res, 200, { ok: true });
|
||
},
|
||
|
||
// Force an immediate Planning Center refresh (the "Sync" button). Auto-fill also
|
||
// runs on its own timer; this just does it now.
|
||
'POST /sync': (req, res) => {
|
||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||
current.refresh(); // scans the photos folder + pulls the plan if Planning Center is configured
|
||
json(res, 200, { ok: true });
|
||
},
|
||
|
||
'GET /people': (req, res) => {
|
||
const state = current ? current.getLatest() : null;
|
||
const options = [{ value: '', label: 'Off' }];
|
||
for (const slot of state?.slots || []) if (slot.person && slot.person.name) options.push({ value: String(slot.index), label: slot.person.name });
|
||
json(res, 200, { options });
|
||
},
|
||
|
||
'GET /photo/*': (req, res) => {
|
||
if (!current) return void res.writeHead(404).end();
|
||
const name = path.basename(decodeURIComponent(req.wildcard || '').replace(/^\/+/, ''));
|
||
if (!name || name.includes('..')) return void res.writeHead(400).end();
|
||
fs.readFile(path.join(current.photoDir, name), (err, buf) => {
|
||
if (err) return void res.writeHead(404).end();
|
||
res.writeHead(200, { 'Content-Type': contentTypeForFile(name), 'Cache-Control': 'public, max-age=60' });
|
||
res.end(buf);
|
||
});
|
||
},
|
||
|
||
'GET /service-types': async (req, res) => {
|
||
if (!current) return json(res, 503, { options: [], error: 'Module not running.' });
|
||
if (!current.pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
|
||
try {
|
||
const [folderBody, stBody] = await Promise.all([current.pco.getAll(`${SERVICES}/folders`), current.pco.getAll(`${SERVICES}/service_types`)]);
|
||
const folders = new Map();
|
||
for (const f of folderBody.data || []) folders.set(String(f.id), { name: String(f.attributes?.name || 'Folder'), parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '' });
|
||
const pathOf = (parentId) => { const names = []; let id = parentId; const seen = new Set(); while (id && folders.has(id) && !seen.has(id)) { seen.add(id); names.unshift(folders.get(id).name); id = folders.get(id).parentId; } return names.join(' › '); };
|
||
const options = (stBody.data || []).map((st) => ({ value: String(st.id), label: String(st.attributes?.name || 'Service type'), group: pathOf(st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '') })).sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
||
json(res, 200, { options });
|
||
} catch (err) { log(`service type list failed: ${err?.message || err}`); json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) }); }
|
||
},
|
||
|
||
'GET /plans': async (req, res) => {
|
||
if (!current) return json(res, 503, { options: [] });
|
||
const params = new URLSearchParams(req.search || '');
|
||
const stId = String(params.get('serviceTypeId') || '').trim();
|
||
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
||
if (!current.pco.configured || !stId) return json(res, 200, { options: [auto] });
|
||
try {
|
||
const plans = await current.listPlans(stId); const now = Date.now();
|
||
const ordered = [...plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate), ...plans.filter((p) => p.sortDate < now - 12 * 3600000).sort((a, b) => b.sortDate - a.sortDate)];
|
||
json(res, 200, { options: [auto, ...ordered.map((p) => ({ value: p.id, label: [p.dates, p.title].filter(Boolean).join(' — ') || p.id }))] });
|
||
} catch (err) { json(res, 502, { options: [auto], error: err?.message || String(err) }); }
|
||
},
|
||
};
|
||
},
|
||
};
|