'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/ → image bytes (library + cached PCO photos) * POST /library/ → rename / set birthday / replace photo (raw image body; ?name=&birthday=) * POST /library//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//name → ?name= rename the occupant (or make a name-only spot) * POST /slot//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[^>]*>([^<]+) ({ 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:"). 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//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=&to=. '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=&personId= (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//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) }); } }, }; }, };