require('dotenv').config(); const express = require('express'); const path = require('path'); const fs = require('fs/promises'); const fsSync = require('fs'); const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const nodemailer = require('nodemailer'); const cron = require('node-cron'); // ─── Config ────────────────────────────────────────────────────── const PORT = parseInt(process.env.PORT || '3000', 10); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const STATE_FILE = path.join(DATA_DIR, 'checklists.json'); // Uploaded equipment photos live under the persisted data volume, so they // survive container rebuilds and are excluded from the deploy tarball. const UPLOAD_DIR = path.join(DATA_DIR, 'uploads'); // Attached documents (setup guides etc.) linked from task/note descriptions. // SEPARATE from uploads/ on purpose: the photo orphan-sweep globs UPLOAD_DIR // and would delete anything it doesn't recognize as an equipment image. const DOCS_DIR = path.join(DATA_DIR, 'docs'); // Nightly snapshots of checklists.json live here (under the persisted volume, so // they survive rebuilds and are excluded from the deploy tarball). const BACKUP_DIR = path.join(DATA_DIR, 'backups'); const BACKUP_KEEP = parseInt(process.env.BACKUP_KEEP || '30', 10); // days of history const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme'; const REPORT_EMAIL = process.env.REPORT_EMAIL || ''; const REPORT_CRON = process.env.REPORT_CRON || '0 15 * * 0'; // Sunday 3:00 PM const REPORT_TZ = process.env.REPORT_TZ || 'America/New_York'; const RESET_AFTER_DIGEST = (process.env.RESET_AFTER_DIGEST || 'true').toLowerCase() !== 'false'; const SMTP_HOST = process.env.SMTP_HOST || ''; const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587', 10); const SMTP_SECURE = (process.env.SMTP_SECURE || 'false').toLowerCase() === 'true'; const SMTP_USER = process.env.SMTP_USER || ''; const SMTP_PASS = process.env.SMTP_PASS || ''; const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER; // Fingerprint of the front-end assets this process serves. Sent with every /api/state poll so an // already-open tab can notice a deploy and refresh itself — signing out/in never reloads an SPA, so // without this a wall tablet runs whatever JS it loaded days ago (this bit us: a new feature's // controls were simply absent in a long-open tab). Computed once at boot from size+mtime. const BUILD_ID = (() => { try { return ['app.js', 'index.html', 'styles.css', 'md.js'] .map((f) => { try { const s = fsSync.statSync(path.join(__dirname, 'public', f)); return f + ':' + s.size + ':' + Math.floor(s.mtimeMs); } catch (_) { return f + ':0'; } }) .join('|'); } catch (_) { return String(Date.now()); } })(); if (!fsSync.existsSync(DATA_DIR)) { fsSync.mkdirSync(DATA_DIR, { recursive: true }); } if (!fsSync.existsSync(UPLOAD_DIR)) { fsSync.mkdirSync(UPLOAD_DIR, { recursive: true }); } if (!fsSync.existsSync(DOCS_DIR)) { fsSync.mkdirSync(DOCS_DIR, { recursive: true }); } if (!fsSync.existsSync(BACKUP_DIR)) { fsSync.mkdirSync(BACKUP_DIR, { recursive: true }); } // ─── Persistence (serialised single-writer queue) ──────────────── let writeQueue = Promise.resolve(); function defaultState() { return { lists: [ { id: 'startup', name: 'Startup', location: '', team: '', items: [], lastCheckDate: new Date().toDateString() } ], selectedListId: 'startup', checks: {}, users: {}, executives: {}, directory: { locations: [], teams: [] }, equipment: { items: {} }, activity: [] // admin audit log: append-only ring buffer of who-did-what (capped, see logActivity) }; } // Lower-case short code seeded for a brand-new location/team. Multi-word names // use initials ("South Coast" -> "sc"); single words use the first two letters // ("Woonsocket" -> "wo"). These are only defaults — the admin can edit them. function defaultAbbrev(name) { const words = String(name || '').trim().split(/\s+/).filter(Boolean); if (words.length >= 2) return words.map((w) => w[0]).join('').toLowerCase(); return String(name || '').trim().slice(0, 2).toLowerCase(); } // One-time seed of the location directory from whatever locations already exist // on users/lists. After this the directory is admin-owned and never re-derived // (re-deriving would resurrect entries the admin deleted). function seedDirectory(state) { const locs = new Set(); (state.lists || []).forEach((l) => { const v = (l.location || '').trim(); if (v) locs.add(normalizeName(v)); }); Object.values(state.users || {}).forEach((u) => { const v = (u.location || '').trim(); if (v) locs.add(normalizeName(v)); }); return { locations: Array.from(locs).sort().map((name) => ({ name, abbrev: defaultAbbrev(name) })), teams: [] }; } // Keep the directory in sync as accounts are created: register a location/team // (with a default abbrev) if it isn't already listed. The master can refine the // abbreviations afterward via PUT /api/admin/directory. function registerDirectory(state, location, team) { state.directory = state.directory || { locations: [], teams: [] }; if (!Array.isArray(state.directory.locations)) state.directory.locations = []; if (!Array.isArray(state.directory.teams)) state.directory.teams = []; const addEntry = (arr, raw) => { const name = (raw || '').trim() ? normalizeName(raw) : ''; if (!name) return; if (!arr.find((e) => normalizeName(e.name) === name)) { arr.push({ name, abbrev: defaultAbbrev(name) }); } }; addEntry(state.directory.locations, location); addEntry(state.directory.teams, team); } // The directory as the UI should see it: curated locations/teams PLUS any teams // actually in use on accounts or lists — so the login team dropdown is never // empty when teams exist. Read-only; does not mutate stored state. function effectiveDirectory(state) { const dir = state.directory || { locations: [], teams: [] }; const teamMap = new Map(); // lowercased name -> { name, abbrev } (dir.teams || []).forEach((t) => { if (t && t.name) teamMap.set(String(t.name).toLowerCase(), { name: t.name, abbrev: t.abbrev || '' }); }); const addTeam = (name) => { const n = String(name || '').trim(); if (n && !teamMap.has(n.toLowerCase())) teamMap.set(n.toLowerCase(), { name: n, abbrev: '' }); }; Object.values(state.users || {}).forEach((u) => addTeam(u.team)); (state.lists || []).forEach((l) => addTeam(l.team)); return { locations: (dir.locations || []).map((e) => ({ name: e.name, abbrev: e.abbrev || '' })), teams: Array.from(teamMap.values()).sort((a, b) => a.name.localeCompare(b.name)) }; } // Drop executive scope entries that reference a location/team that no longer // exists ANYWHERE — deleted from the directory with nothing (no list, no team // login) still carrying the name. Deleting "Greenville" from the directory used // to leave every executive scoped to it showing (and holding) a Greenville // grant forever. Behavior-preserving by construction: a dangling name matches // no list, so dropping it can't shrink real access — and an axis is never // emptied while the other stays scoped (an empty axis means "any" in // execCanEditList, so that scrub would BROADEN access; the stale name is kept // instead and the admin UI labels it "removed"). Both axes emptying together is // fine: no scope at all = no access, which is what a dangling-only scope // already amounted to. Returns the changed executives (for session refresh). function scrubExecScopes(state) { const dir = state.directory || { locations: [], teams: [] }; const validLocs = new Set((dir.locations || []).map((e) => normalizeName(e.name))); const validTeams = new Set((dir.teams || []).map((e) => normalizeName(e.name))); (state.lists || []).forEach((l) => { if ((l.location || '').trim()) validLocs.add(normalizeName(l.location)); if ((l.team || '').trim()) validTeams.add(normalizeName(l.team)); }); Object.values(state.users || {}).forEach((u) => { if ((u.location || '').trim()) validLocs.add(normalizeName(u.location)); if ((u.team || '').trim()) validTeams.add(normalizeName(u.team)); }); const changed = []; Object.values(state.executives || {}).forEach((e) => { const curLocs = Array.isArray(e.scopeLocations) ? e.scopeLocations : []; const curTeams = Array.isArray(e.scopeTeams) ? e.scopeTeams : []; const keptLocs = curLocs.filter((s) => validLocs.has(normalizeName(s))); const keptTeams = curTeams.filter((s) => validTeams.has(normalizeName(s))); let nextLocs = keptLocs; let nextTeams = keptTeams; if (keptLocs.length === 0 && curLocs.length > 0 && keptTeams.length > 0) nextLocs = curLocs; // would broaden → keep stale if (keptTeams.length === 0 && curTeams.length > 0 && keptLocs.length > 0) nextTeams = curTeams; // would broaden → keep stale if (nextLocs.length !== curLocs.length || nextTeams.length !== curTeams.length) { e.scopeLocations = nextLocs; e.scopeTeams = nextTeams; changed.push(e); } }); return changed; } // Convert any legacy state shape into the current model. function migrateState(state) { if (!state || !Array.isArray(state.lists)) return defaultState(); if (!state.checks || typeof state.checks !== 'object') state.checks = {}; if (!state.users || typeof state.users !== 'object') state.users = {}; if (!state.executives || typeof state.executives !== 'object') state.executives = {}; if (!state.equipment || typeof state.equipment !== 'object') state.equipment = { items: {} }; if (!state.equipment.items || typeof state.equipment.items !== 'object') state.equipment.items = {}; if (!Array.isArray(state.activity)) state.activity = []; // admin audit log (additive, never wiped) if (!Array.isArray(state.incidents)) state.incidents = []; // Phase 4: reported gear problems (additive) // Equipment scope (additive): existing items have no scope, which means // "global" — they keep linking everywhere, exactly as before. New per-site // items carry the locations/teams they belong to. Object.values(state.equipment.items).forEach((e) => { if (!Array.isArray(e.scopeLocations)) e.scopeLocations = []; if (!Array.isArray(e.scopeTeams)) e.scopeTeams = []; // Multi-photo migration (additive/idempotent): fold a legacy single `image` // into an images[] list of {url,caption}, and keep `image` mirrored to the first. e.images = normEquipImages(e.images, e.image); e.image = e.images.length ? e.images[0].url : ''; // Attached documents (additive): older entries simply get an empty list. e.docs = normEquipDocs(e.docs); }); // Teams expansion — all additive and idempotent: only fill fields when absent // so re-running on every load never overwrites real data. Object.values(state.users).forEach((u) => { if (typeof u.team !== 'string') u.team = ''; if (!Array.isArray(u.emailRecipients)) u.emailRecipients = []; }); if (!state.directory || typeof state.directory !== 'object') { state.directory = seedDirectory(state); } else { if (!Array.isArray(state.directory.locations)) state.directory.locations = []; if (!Array.isArray(state.directory.teams)) state.directory.teams = []; } state.lists.forEach((list) => { if (typeof list.location !== 'string') list.location = ''; if (typeof list.team !== 'string') list.team = ''; if (typeof list.color !== 'string') list.color = ''; if (typeof list.hidden !== 'boolean') list.hidden = false; // Notes section (additive): off by default so existing lists look identical. columnOrder is the // shared To Do/Done/Notes order; sanitizeColumnOrder fills it to the full valid set in some order. if (typeof list.showNotes !== 'boolean') list.showNotes = false; // Notes-only list: the To Do/Done columns are hidden (a reference board). Implies showNotes. if (typeof list.notesOnly !== 'boolean') list.notesOnly = false; if (list.notesOnly) list.showNotes = true; // Feedback column (additive): off by default; entries live in feedback[], never in items[]. if (typeof list.showFeedback !== 'boolean') list.showFeedback = false; list.feedback = cleanFeedback(list.feedback); // Task sections (additive): legacy lists get the single default "To Do". list.taskGroups = sanitizeTaskGroups(list.taskGroups); // Requests inbox (additive): the per-team list cross-department requests land in. // Requests are ONE-SHOT — a reset schedule would "reopen" completed ones — so the // flag forces the schedule off everywhere a schedule could be written. if (typeof list.isRequests !== 'boolean') list.isRequests = false; if (list.isRequests) list.resetSchedule = null; list.columnOrder = sanitizeColumnOrder(list.columnOrder); if (!list.resetSchedule || typeof list.resetSchedule !== 'object') list.resetSchedule = null; // Always have a lastCheckDate so the once-per-day reset guard works. Default to // TODAY (not a past date) so a list that's missing it never auto-resets on the // very first load — which would wipe checks (esp. with a 'daily' schedule). if (typeof list.lastCheckDate !== 'string' || !list.lastCheckDate) list.lastCheckDate = new Date().toDateString(); list.items = Array.isArray(list.items) ? list.items : []; list.items.forEach((item) => { // Normalize kind first: anything that isn't the string 'note' is a task (covers legacy items // with no kind, and imported/hand-edited garbage). A note has no completion state, so skip the // legacy completed→checks migration for it (never mint a stray check record for a note). item.kind = item.kind === 'note' ? 'note' : 'task'; // Request attribution + photos (additive, tasks only): light re-sanitize. if (item.requestedBy != null) item.requestedBy = String(item.requestedBy).slice(0, 60); if (item.requestedAt != null) item.requestedAt = String(item.requestedAt).slice(0, 40); if (item.images != null) item.images = normEquipImages(item.images); if (item.kind === 'note') { delete item.completed; delete item.completedDate; delete item.completedBy; return; } if (item.completed && item.completedBy) { const key = normalizeName(item.completedBy); state.checks[key] = state.checks[key] || {}; state.checks[key][list.id] = state.checks[key][list.id] || {}; if (!state.checks[key][list.id][item.id]) { state.checks[key][list.id][item.id] = { completed: true, completedDate: item.completedDate || new Date().toISOString(), completedBy: key }; } } delete item.completed; delete item.completedDate; delete item.completedBy; }); reconcileItemGroups(list); // every task lands in a real section (deleted/unknown → default) }); // Convert the legacy structured sub-step model to inline "- [ ]" task lines (idempotent). migrateLegacySubtasks(state); // Retro-clean executive scopes that already reference deleted locations/teams // (directory deletes didn't cascade before this existed). Idempotent; persists // on the next state write. Runs at boot, so no live sessions need refreshing. scrubExecScopes(state); return state; } // ─── Admin audit log ──────────────────────────────────────────────── // Append-only record of who-did-what, kept in state.activity (persisted with the state file, // preserved across the master bulk-save). Capped to the most recent MAX_ACTIVITY entries so the // file can't grow without bound. Master-only to read (GET /api/admin/activity). const MAX_ACTIVITY = 1000; function activityRole(user) { if (isMasterAdmin(user)) return 'master'; if (user.role === 'executive') return 'executive'; if (user.role === 'admin') return 'team-admin'; return 'checker'; } // A list's human label for log messages, e.g. "Startup (Woonsocket · Production)". // ── Feedback entries ───────────────────────────────────────────────── // A list's optional "Feedback" column: lightweight text entries ANYONE the // list applies to may add (checkers included — that's the point: team input, // things to work on). Stored on the list as feedback[] — deliberately NOT in // items[], so nothing in the task/note machinery (checks, resets, digests, // completion %) can ever count one. function makeFeedbackId() { return 'fb-' + Date.now().toString(36) + '-' + Math.random().toString(16).slice(2, 8); } function cleanFeedback(arr) { if (!Array.isArray(arr)) return []; return arr.map((f) => { if (!f || typeof f !== 'object') return null; const text = String(f.text || '').trim().slice(0, 2000); if (!text) return null; const out = { id: String(f.id || makeFeedbackId()), text, by: String(f.by || '').trim().slice(0, 40), byUser: String(f.byUser || '').slice(0, 80), createdAt: f.createdAt || new Date().toISOString() }; if (f.editedAt) out.editedAt = f.editedAt; return out; }).filter(Boolean); } function listLabel(list) { if (!list) return '(unknown list)'; const scope = [list.location, list.team].map((s) => (s || '').trim()).filter(Boolean).join(' · '); return (list.name || '(unnamed)') + (scope ? ' (' + scope + ')' : ''); } function logActivity(state, user, kind, msg, whoOverride) { if (!state) return; if (!Array.isArray(state.activity)) state.activity = []; const who = String((whoOverride && whoOverride.trim()) || user.displayName || user.username || '?').slice(0, 60); state.activity.push({ ts: new Date().toISOString(), who, role: activityRole(user), kind: String(kind || 'action'), msg: String(msg || '').slice(0, 280) }); if (state.activity.length > MAX_ACTIVITY) state.activity = state.activity.slice(-MAX_ACTIVITY); } async function loadState() { try { const raw = await fs.readFile(STATE_FILE, 'utf-8'); const parsed = JSON.parse(raw); if (!Array.isArray(parsed.lists) || parsed.lists.length === 0) { return defaultState(); } return migrateState(parsed); } catch (err) { if (err.code === 'ENOENT') { const fresh = defaultState(); await saveStateNow(fresh); return fresh; } throw err; } } async function saveStateNow(state) { const tmp = `${STATE_FILE}.tmp`; await fs.writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8'); await fs.rename(tmp, STATE_FILE); } function saveState(state) { writeQueue = writeQueue.then(() => saveStateNow(state)).catch((err) => { console.error('Failed to persist state:', err); }); return writeQueue; } // ─── Recurrence reset (operates on per-location checks map) ────── // A task recurs (its checkmark clears) when a new period has begun since it was // completed. Uses the SAME schedule model as a list reset: every-N interval (via // resetScheduleDue) plus optional intraday clock times. Interval-1 daily/weekly/ // monthly/yearly reproduce the original once-per-period behavior exactly. function shouldRecur(item, check) { if (!item.recurrence || !check || !check.completed) return false; const rec = item.recurrence; const now = new Date(); const completed = new Date(check.completedDate); if (isNaN(completed.getTime())) return false; const times = Array.isArray(rec.times) ? rec.times : []; // Intraday: the task returns to To Do at each configured clock time (church-local) // on the days its schedule is due — recurs once per slot boundary passed since it // was completed. Mirrors a list's intraday reset (tzNow/latestPassedSlot in REPORT_TZ). if (times.length) { if (!resetScheduleDue(rec, tzNow(now, REPORT_TZ))) return false; const slotKey = latestPassedSlot(times, now); // "YYYY-MM-DD HH:MM" or '' if (!slotKey) return false; const cwc = tzWallClock(completed, REPORT_TZ); const completedKey = cwc.date + ' ' + String(Math.floor(cwc.minutes / 60)).padStart(2, '0') + ':' + String(cwc.minutes % 60).padStart(2, '0'); return completedKey < slotKey; // completed before the latest passed slot → recur } // Non-intraday: today is a scheduled day (honoring every-N interval) AND the task // was completed before the start of today. if (!resetScheduleDue(rec, now)) return false; const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); return completed.getTime() < startOfToday; } // Is a list's optional auto-reset schedule due on `now`? (weekly weekday / monthly date) function resetScheduleDue(schedule, now) { if (!schedule || typeof schedule !== 'object') return false; const N = Math.max(1, parseInt(schedule.interval, 10) || 1); const a = schedule.anchor ? new Date(schedule.anchor) : null; const hasAnchor = a && !isNaN(a.getTime()); // Whole days between the anchor and now (UTC midnights → DST-proof). const daysSince = hasAnchor ? Math.round((Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) - Date.UTC(a.getFullYear(), a.getMonth(), a.getDate())) / 86400000) : 0; // For interval 1, always on cycle (original behavior). For N>1, need a valid // anchor and the unit-count since it must land on a multiple of N (and not be // before the anchor). const onCycle = (units) => N <= 1 || (hasAnchor && units >= 0 && units % N === 0); const lastDayOfMonth = () => new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); if (schedule.type === 'daily') return onCycle(daysSince); if (schedule.type === 'weekly') { if (now.getDay() !== schedule.dayOfWeek) return false; return onCycle(Math.floor(daysSince / 7)); } if (schedule.type === 'monthly') { if (now.getDate() !== Math.min(schedule.dayOfMonth, lastDayOfMonth())) return false; const months = hasAnchor ? (now.getFullYear() - a.getFullYear()) * 12 + (now.getMonth() - a.getMonth()) : 0; return onCycle(months); } if (schedule.type === 'yearly') { if (now.getMonth() !== schedule.month) return false; if (now.getDate() !== Math.min(schedule.dayOfMonth, lastDayOfMonth())) return false; return onCycle(hasAnchor ? (now.getFullYear() - a.getFullYear()) : 0); } return false; } // The church's wall clock at `now`: the calendar date (YYYY-MM-DD) and minutes // since midnight, both in REPORT_TZ. Used for the intraday multi-time reset so a // list repeats at the operator's LOCAL service times regardless of the container's // system timezone. Wall-clock based, so it's DST-proof (no offset arithmetic). function tzWallClock(now, tz) { const parts = {}; for (const p of new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(now)) parts[p.type] = p.value; const hour = (parseInt(parts.hour, 10) || 0) % 24; // some platforms emit '24' at midnight return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: hour * 60 + (parseInt(parts.minute, 10) || 0) }; } // Of a list's reset times, the latest one (today, in REPORT_TZ) whose clock time // has already passed — returned as a stable slot key like "2026-06-28 17:00". // Empty string if none have passed yet today. The key changes when a later time // passes or the day rolls over, which is exactly when a fresh reset is due. function latestPassedSlot(times, now) { const wc = tzWallClock(now, REPORT_TZ); let best = ''; let bestMin = -1; for (const t of times) { const m = /^(\d{2}):(\d{2})$/.exec(t); if (!m) continue; const mins = parseInt(m[1], 10) * 60 + parseInt(m[2], 10); if (mins <= wc.minutes && mins > bestMin) { bestMin = mins; best = t; } } return best ? `${wc.date} ${best}` : ''; } // A Date whose LOCAL calendar fields (getDay/getDate/getMonth/getFullYear) equal // the wall clock in `tz`. The intraday branch evaluates resetScheduleDue() against // THIS so its due-day judgment shares REPORT_TZ with latestPassedSlot — otherwise, // with the container in UTC, a weekly/biweekly list near the day boundary would // judge "due" on the wrong calendar day and reset early (or skip). Legacy // (time-less) lists keep using the raw process-local `now`, so their long-standing // once-per-day behavior is untouched. function tzNow(now, tz) { const wc = tzWallClock(now, tz); const [y, mo, d] = wc.date.split('-').map(Number); return new Date(y, mo - 1, d, Math.floor(wc.minutes / 60), wc.minutes % 60, 0, 0); } function applyRecurrenceResets(state) { const now = new Date(); const today = now.toDateString(); let changed = false; state.checks = state.checks || {}; state.lists.forEach((list) => { const sched = list.resetSchedule; const times = (sched && Array.isArray(sched.times)) ? sched.times : []; // Intraday multi-time reset: a list configured with specific clock times sends // the WHOLE list back to To Do at each of those times (church-local) on the days // its schedule is due — so it can recur several times in one day. Tracked by // `lastResetSlot` independently of the once-per-day `lastCheckDate` guard below. if (times.length && resetScheduleDue(sched, tzNow(now, REPORT_TZ))) { const slot = latestPassedSlot(times, now); if (slot) { if (list.lastResetSlot == null) { // First time we've seen this intraday list (e.g. just configured): record // the current slot WITHOUT wiping — so enabling it mid-day never clears // checks someone just made. Real resets start at the next slot boundary. list.lastResetSlot = slot; list.lastCheckDate = today; changed = true; } else if (list.lastResetSlot !== slot) { Object.keys(state.checks).forEach((locKey) => { if (state.checks[locKey] && state.checks[locKey][list.id]) { delete state.checks[locKey][list.id]; changed = true; } }); list.lastResetSlot = slot; list.lastCheckDate = today; changed = true; } } return; // intraday lists are governed solely by the times[] path } const alreadyToday = list.lastCheckDate === today; // Whole-list scheduled reset (a list-level daily/weekly/… schedule with no // intraday times): send the WHOLE list back to To Do — but ONCE per day, so it // never re-wipes checks made after the morning reset. Supersedes per-item today. const scheduledReset = resetScheduleDue(list.resetSchedule, now); if (scheduledReset && !alreadyToday) { Object.keys(state.checks).forEach((locKey) => { if (state.checks[locKey] && state.checks[locKey][list.id]) { delete state.checks[locKey][list.id]; changed = true; } }); list.lastCheckDate = today; changed = true; return; } // Per-item recurrence. Normally once per day (the lastCheckDate guard), but if // any item repeats at intraday clock times we must re-evaluate on every poll so // those tasks reset at each time slot. shouldRecur is idempotent — it only clears // a check once per elapsed period — so scanning every poll can't over-reset. const hasIntradayItem = (list.items || []).some((it) => it.recurrence && Array.isArray(it.recurrence.times) && it.recurrence.times.length); if (alreadyToday && !hasIntradayItem) return; Object.keys(state.checks).forEach((locKey) => { const checksForList = state.checks[locKey] && state.checks[locKey][list.id]; if (!checksForList) return; list.items.forEach((item) => { const c = checksForList[item.id]; if (shouldRecur(item, c)) { delete checksForList[item.id]; changed = true; } }); }); if (!alreadyToday) { list.lastCheckDate = today; changed = true; } }); return changed; } // Keep `lastResetSlot` SERVER-OWNED — it must never be trusted from a client // payload (it's runtime state, like lastCheckDate). Call this on every write path // that sets a list's resetSchedule, passing the list's PRIOR server state as `prev`: // • intraday times newly set or changed → re-anchor to the current slot, so // configuring/editing never causes a surprise mid-day wipe and the next // scheduled slot still fires (the slot key differs once a new time/day lands). // • intraday times unchanged → keep the server's existing slot, so an undo or an // unrelated structural save can't rewind the cadence and re-wipe live checks. // • no longer intraday → drop the field. function reconcileResetSlot(target, prev) { const times = (target.resetSchedule && Array.isArray(target.resetSchedule.times)) ? target.resetSchedule.times : null; if (!times) { delete target.lastResetSlot; return; } const prevTimes = (prev && prev.resetSchedule && Array.isArray(prev.resetSchedule.times)) ? prev.resetSchedule.times : null; const unchanged = !!prevTimes && prevTimes.length === times.length && prevTimes.every((t, i) => t === times[i]); if (unchanged && prev.lastResetSlot != null) { target.lastResetSlot = prev.lastResetSlot; } else { target.lastResetSlot = latestPassedSlot(times, new Date()); } } // ─── App ───────────────────────────────────────────────────────── const app = express(); // ─── CORS for the native App Store / Play Store builds ─────────────────────── // The iOS and Android apps ship the SAME front-end assets from server/public/ // inside their app bundle, then call this server as a cross-origin JSON API. // Capacitor serves those bundles from a fixed local shell origin, so without // these headers every /api call from a native app dies at the preflight. // // Deliberately an ALLOWLIST of those fixed shell origins — never `*` and never // a reflected arbitrary Origin. Auth here is a Bearer token with no cookies, so // Access-Control-Allow-Credentials is intentionally omitted: even if a hostile // page reached this header it has no ambient session to ride. Extra origins for // local `cap run` live-reload can be added via the NATIVE_CORS_ORIGINS env var // (comma-separated, exact origins incl. port) without touching this list. const NATIVE_CORS_ORIGINS = new Set([ 'capacitor://localhost', // iOS shell (WKWebView custom scheme) 'https://localhost', // Android shell (default androidScheme) 'ionic://localhost', // legacy Capacitor/Ionic shell ...String(process.env.NATIVE_CORS_ORIGINS || '') .split(',').map((o) => o.trim()).filter(Boolean), ]); app.use((req, res, next) => { const origin = req.headers.origin; if (origin && NATIVE_CORS_ORIGINS.has(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type'); res.setHeader('Access-Control-Max-Age', '86400'); // Answer the preflight here, before the body parsers and every route. if (req.method === 'OPTIONS') return res.status(204).end(); } next(); }); // Body parsing. Keep a tight limit globally so unauthenticated/low-privilege routes // (e.g. /api/login) can't be forced to buffer+parse huge payloads, and give ONLY the // authenticated photo-upload route the headroom a full-resolution image needs. Base64 // inflates ~33%, so 30mb comfortably carries the endpoint's 20 MB decoded cap. const equipUploadJson = express.json({ limit: '48mb' }); // fits a 30 MB video base64-encoded (~40 MB) + overhead const defaultJson = express.json({ limit: '5mb' }); app.use((req, res, next) => ((req.path === '/api/equipment/upload-image' || req.path === '/api/documents/upload') ? equipUploadJson : defaultJson)(req, res, next)); // Privacy policy. Both app stores require a publicly reachable policy URL, and reviewers // open it while signed out — so this sits BEFORE any auth and serves the file directly. // The clean /privacy path is what gets registered with Apple and Google; /privacy.html also // works via the static handler below, so an old link never breaks. app.get('/privacy', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'privacy.html')); }); app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-store, must-revalidate'); } })); // Uploaded equipment photos. Cached aggressively because each filename is // content-unique (random) — a replaced photo gets a new name, so it's safe. app.use('/equip-images', express.static(UPLOAD_DIR, { index: false, setHeaders: (res) => { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } })); // Attached documents (linked from task/note descriptions). Same content-unique // random-prefix filenames as photos, so aggressive caching is safe here too. app.use('/doc-files', express.static(DOCS_DIR, { index: false, setHeaders: (res) => { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } })); let cachedState = null; async function getState() { if (!cachedState) { cachedState = await loadState(); } if (applyRecurrenceResets(cachedState)) { saveState(cachedState); } return cachedState; } // ─── Sessions / Auth ──────────────────────────────────────────── // Sessions live in memory; on server restart everyone re-logs in. // That's fine for a low-stakes app and avoids leaking tokens on disk. const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days const sessions = new Map(); function newSessionToken() { return crypto.randomBytes(24).toString('base64url'); } function publicUser(u) { if (!u) return null; return { username: u.username, displayName: u.displayName, location: u.location || '', team: u.team || '', role: u.role || 'user', emailRecipients: Array.isArray(u.emailRecipients) ? u.emailRecipients : [] }; } function userSession(u) { return { username: u.username, displayName: u.displayName || u.username, location: u.location || '', team: u.team || '', role: u.role || 'user' }; } // The stored team-login record behind a session (req.user is a slim snapshot). function findUserRecord(state, sessionUser) { const users = state.users || {}; if (sessionUser && users[sessionUser.username]) return users[sessionUser.username]; return Object.values(users).find((u) => u.username === (sessionUser && sessionUser.username)) || {}; } function executiveSession(exec) { return { username: 'exec:' + exec.id, displayName: exec.name, location: '', team: '', role: 'executive', scopeLocations: Array.isArray(exec.scopeLocations) ? exec.scopeLocations : [], scopeTeams: Array.isArray(exec.scopeTeams) ? exec.scopeTeams : [] }; } function findExecutive(state, name) { const n = normalizeName(name); return Object.values(state.executives || {}).find((e) => normalizeName(e.name) === n) || null; } // Resolve a login from { username?, location, team, password }. The new login // form sends the typed location value as BOTH `location` and `username` (and the // team as `team`), so all three resolution paths below stay reachable: // 1) master admin (env) — typed as "admin" // 2) location + team, with abbreviation expansion ("sc" -> "South Coast") // 3) legacy bare-username (existing per-location logins) — unchanged async function authenticate(creds) { creds = creds || {}; const password = String(creds.password || ''); if (!password) return null; const typedUser = String(creds.username || '').trim().toLowerCase(); const locInput = String(creds.location || '').trim(); const teamInput = String(creds.team || '').trim(); // 1) Master login — labeled "Executive" in the UI (kept distinct from the // separate "Admin" TEAM). "admin" still works as a trigger so we never lock // the master out, but it's never offered to users as a choice. Can't collide // with the Admin team, which arrives in the separate `team` field. const masterCandidate = typedUser || locInput.toLowerCase(); if (masterCandidate === 'executive' || masterCandidate === 'admin') { const state = await getState(); // A "Who are you?" name means a named executive; match by name + their password. const execName = String(creds.name || '').trim(); if (execName) { const exec = findExecutive(state, execName); if (exec && await bcrypt.compare(password, exec.passwordHash || '')) { return executiveSession(exec); } } // The shared ADMIN_PASSWORD is the owner/master — works with or without a name, // so the owner can never be locked out. if (password === ADMIN_PASSWORD) { return { username: 'admin', displayName: 'Executive', location: '', team: '', role: 'admin' }; } return null; } const state = await getState(); // 2) Location + team (abbreviations expanded against the directory). if (locInput) { const loc = normalizeName(expandAbbrev(locInput, 'locations', state.directory)); const tm = teamInput ? normalizeName(expandAbbrev(teamInput, 'teams', state.directory)) : ''; const match = Object.values(state.users || {}).find((u) => { const uTeam = (u.team || '').trim() ? normalizeName(u.team) : ''; return normalizeName(u.location || '') === loc && uTeam === tm; }); if (match) { const ok = await bcrypt.compare(password, match.passwordHash || ''); return ok ? userSession(match) : null; } } // 3) Legacy bare-username fallback. if (typedUser) { const stored = state.users && state.users[typedUser]; if (stored) { const ok = await bcrypt.compare(password, stored.passwordHash || ''); if (ok) return userSession(stored); } } return null; } function requireUser(req, res, next) { const auth = req.get('Authorization') || ''; const m = auth.match(/^Bearer\s+(.+)$/); const token = m && m[1]; if (!token) return res.status(401).json({ error: 'Not signed in' }); const session = sessions.get(token); if (!session) return res.status(401).json({ error: 'Session expired' }); if (session.expires < Date.now()) { sessions.delete(token); return res.status(401).json({ error: 'Session expired' }); } // Refresh expiry on each use (sliding window). session.expires = Date.now() + SESSION_TTL_MS; req.user = session.user; req.sessionToken = token; next(); } // Master admin = the env "admin" login (and any role:'admin' account with no // team). Full control: user management, directory, bulk state, digests. function isMasterAdmin(user) { return !!(user && user.role === 'admin' && !(user.team && String(user.team).trim())); } function requireMasterAdmin(req, res, next) { requireUser(req, res, () => { if (!isMasterAdmin(req.user)) return res.status(403).json({ error: 'Master admin only' }); next(); }); } // Editors = role 'admin' (team admin / master) OR 'executive'. Guards the scoped // list/item endpoints, which separately enforce ownsList() — so this only gates // "is an editor at all," and scope is checked per-request. function requireTeamAdmin(req, res, next) { requireUser(req, res, () => { if (req.user.role !== 'admin' && req.user.role !== 'executive') { return res.status(403).json({ error: 'Editors only' }); } next(); }); } // Public health check (used by Docker HEALTHCHECK, no auth required) app.get('/healthz', (req, res) => { res.json({ ok: true, ts: new Date().toISOString() }); }); // Public: location/team names + abbreviations for the login pickers. No secrets. app.get('/api/directory', async (req, res) => { try { const state = await getState(); res.json(effectiveDirectory(state)); } catch (err) { res.json({ locations: [], teams: [] }); } }); // Auth endpoints app.post('/api/login', async (req, res) => { try { const user = await authenticate(req.body || {}); if (!user) return res.status(401).json({ error: 'Invalid login. Check your location, team, and password.' }); const token = newSessionToken(); sessions.set(token, { user, expires: Date.now() + SESSION_TTL_MS }); // Record the sign-in for the admin activity log (best-effort: never block login on a log write). try { const st = await getState(); logActivity(st, user, 'login', 'signed in'); await saveState(st); } catch (_) {} res.json({ token, user }); } catch (err) { console.error(err); res.status(500).json({ error: 'Login failed' }); } }); app.post('/api/logout', requireUser, (req, res) => { sessions.delete(req.sessionToken); res.json({ ok: true }); }); app.get('/api/me', requireUser, (req, res) => { res.json({ user: req.user }); }); // Public state view (anyone logged in). Strips users/passwords. app.get('/api/state', requireUser, async (req, res) => { try { const state = await getState(); const safe = { lists: state.lists, selectedListId: state.selectedListId, checks: state.checks || {}, directory: effectiveDirectory(state), // Equipment is projected per caller (see equipmentVisibleTo) — the full // library used to ship to every login, so a checker's global search // surfaced every campus's gear. equipment: (() => { const all = (state.equipment && state.equipment.items) || {}; const items = {}; Object.keys(all).forEach((id) => { if (equipmentVisibleTo(req.user, all[id])) items[id] = all[id]; }); return { items }; })(), // Send the user list only to the master admin, never with password hashes. users: isMasterAdmin(req.user) ? Object.values(state.users || {}).map((u) => ({ username: u.username, displayName: u.displayName, location: u.location || '', team: u.team || '', role: u.role || 'user', emailRecipients: Array.isArray(u.emailRecipients) ? u.emailRecipients : [], createdAt: u.createdAt })) : null, executives: isMasterAdmin(req.user) ? Object.values(state.executives || {}).map(publicExec) : null, // A shared team login's roster of volunteer names (for "tap your name"). myRoster: req.user.role === 'user' ? (findUserRecord(state, req.user).roster || []) : [], // Gear-problem reports the caller is allowed to see (their scope only — no // cross-scope leak). Newest-first, capped so a busy campus can't bloat the // payload; the client uses these for gear-detail issue history + the editor // incidents view. See incidentVisibleTo(). incidents: (Array.isArray(state.incidents) ? state.incidents : []) .filter((inc) => incidentVisibleTo(req.user, inc)) .slice(-300) .reverse(), // Departments an editor can send a request to: every real (location, team) // pair, from team logins and lists. Editor-only (the send UI is editor-only). requestTargets: (isMasterAdmin(req.user) || req.user.role === 'executive') ? (() => { const seen = new Map(); const add = (locV, teamV) => { const L = String(locV || '').trim(), T = String(teamV || '').trim(); if (!L || !T) return; const k = normalizeName(L) + '::' + normalizeName(T); if (!seen.has(k)) seen.set(k, { location: L, team: T }); }; Object.values(state.users || {}).forEach((u) => add(u.location, u.team)); (state.lists || []).forEach((l) => add(l.location, l.team)); return [...seen.values()].sort((a, b) => (a.location + ' ' + a.team).localeCompare(b.location + ' ' + b.team)); })() : null, // Which build of the front-end this server is serving. The client compares it against the // build it booted with and refreshes itself when they differ — otherwise a tab left open on a // wall tablet keeps running old JS forever (signing out/in does NOT reload an SPA). buildId: BUILD_ID }; res.json(safe); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to load state' }); } }); // Resolve WHO may toggle checks on `list`, the per-location storage KEY their marks // land under, and the attribution name. Shared by /api/check and /api/check/sub so the // two can never drift on scope, key, or attribution. Master/executives write into the // LIST's own team scope (so their marks merge with that team's live record); everyone // else writes into their own scope; global lists fall back to the caller's display name. // On a shared TEAM login a role 'user' may override `by` (tap-your-name attribution). function resolveCheckScope(state, req, list) { const allowed = isMasterAdmin(req.user) || (req.user.role === 'executive' && execCanEditList(req.user, list)) || (req.user.role !== 'executive' && listAppliesToScope(list, req.user.location, req.user.team)); const userKey = (isMasterAdmin(req.user) || req.user.role === 'executive') ? (scopeKey(list.location, list.team) || normalizeName(req.user.displayName || req.user.username)) : (scopeKey(req.user.location, req.user.team) || normalizeName(req.user.displayName || req.user.username)); let by = req.user.displayName || req.user.username; if (req.user.role === 'user') { const named = String(req.body.by || '').trim().slice(0, 40); if (named) by = named; } return { allowed, userKey, by }; } // Toggle an item's completed status. Uses the logged-in user's location. app.post('/api/check', requireUser, async (req, res) => { try { const { listId, itemId, completed } = req.body || {}; if (!listId || !itemId || typeof completed !== 'boolean') { return res.status(400).json({ error: 'listId, itemId, completed required' }); } const state = await getState(); const list = state.lists.find((l) => l.id === listId); if (!list) return res.status(404).json({ error: 'List not found' }); const item = list.items.find((i) => i.id === itemId); if (!item) return res.status(404).json({ error: 'Item not found' }); // A note is never checkable. No-op with 200 (NEVER 4xx: apiFetch throws on non-2xx and the offline // flush queue keeps a failed op at its head, so a 400 here would wedge every later check-off). A // stale/old client that posts a check for a note is thus harmlessly reconciled on its next poll. if (item.kind === 'note') return res.json({ ok: true, checks: state.checks }); const { allowed, userKey, by } = resolveCheckScope(state, req, list); if (!allowed) return res.status(403).json({ error: 'This list is not assigned to you' }); if (!userKey) return res.status(400).json({ error: 'No scope set for this check' }); state.checks = state.checks || {}; state.checks[userKey] = state.checks[userKey] || {}; state.checks[userKey][listId] = state.checks[userKey][listId] || {}; if (completed) { state.checks[userKey][listId][itemId] = { completed: true, completedDate: new Date().toISOString(), completedBy: by }; } else { delete state.checks[userKey][listId][itemId]; } logActivity(state, req.user, completed ? 'check' : 'uncheck', (completed ? 'checked' : 'unchecked') + ' "' + (item.title || '?') + '" in ' + listLabel(list), by); await saveState(state); res.json({ ok: true, checks: state.checks }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to update item' }); } }); // Toggle ONE sub-step (checkbox) of a task by its 0-based INDEX among the task-list lines // in item.description. Progress is stored per-location INSIDE the item's check object // (checks[userKey][listId][itemId].subChecks[index] = {by,at}). The server owns the count // (parsed from the description, so completion can't be spoofed); the task auto-completes // when EVERY checkbox is checked and reopens when one is unchecked — writing the exact same // {completed,completedDate,completedBy} shape /api/check produces, so the client's To Do↔Done // move needs no special-casing. An out-of-range index (stale offline op after the description // shrank) is ACKed as a no-op rather than 4xx, so the offline flush queue never wedges. app.post('/api/check/sub', requireUser, async (req, res) => { try { const { listId, itemId, index, checked } = req.body || {}; if (!listId || !itemId || !Number.isInteger(index) || index < 0 || typeof checked !== 'boolean') { return res.status(400).json({ error: 'listId, itemId, index, checked required' }); } const state = await getState(); const list = state.lists.find((l) => l.id === listId); if (!list) return res.status(404).json({ error: 'List not found' }); const item = list.items.find((i) => i.id === itemId); if (!item) return res.status(404).json({ error: 'Item not found' }); // Notes are display-only — sub-steps never toggle. 200 no-op (never 4xx; see /api/check). if (item.kind === 'note') return res.json({ ok: true, checks: state.checks, completed: false, total: 0, doneCount: 0 }); const total = taskLineCount(item.description); const { allowed, userKey, by } = resolveCheckScope(state, req, list); if (!allowed) return res.status(403).json({ error: 'This list is not assigned to you' }); if (!userKey) return res.status(400).json({ error: 'No scope set for this check' }); // Stale index (description changed under an offline op) → no-op, return current state. if (index >= total) { const cur = state.checks[userKey] && state.checks[userKey][listId] && state.checks[userKey][listId][itemId]; return res.json({ ok: true, checks: state.checks, completed: !!(cur && cur.completed), total, doneCount: 0 }); } state.checks = state.checks || {}; state.checks[userKey] = state.checks[userKey] || {}; state.checks[userKey][listId] = state.checks[userKey][listId] || {}; const rec = state.checks[userKey][listId][itemId] || {}; rec.subChecks = rec.subChecks || {}; const key = String(index); if (checked) rec.subChecks[key] = { by, at: new Date().toISOString() }; else delete rec.subChecks[key]; // Completion counts only in-range indices (guards against a stale key lingering). const doneCount = Object.keys(rec.subChecks).filter((k) => { const n = Number(k); return Number.isInteger(n) && n >= 0 && n < total; }).length; const nowComplete = total > 0 && doneCount === total; const wasComplete = !!rec.completed; if (nowComplete && !wasComplete) { rec.completed = true; rec.completedDate = new Date().toISOString(); rec.completedBy = by; logActivity(state, req.user, 'check', 'completed all steps of "' + (item.title || '?') + '" in ' + listLabel(list), by); } else if (!nowComplete && wasComplete) { rec.completed = false; delete rec.completedDate; delete rec.completedBy; logActivity(state, req.user, 'uncheck', 'reopened "' + (item.title || '?') + '" in ' + listLabel(list), by); } // Intermediate sub-toggles log NOTHING (only the two parent transitions), so a // 10-step task can't flood the activity ring buffer. if (!rec.completed && Object.keys(rec.subChecks).length === 0) delete state.checks[userKey][listId][itemId]; else state.checks[userKey][listId][itemId] = rec; await saveState(state); res.json({ ok: true, checks: state.checks, completed: !!rec.completed, total, doneCount }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to update sub-step' }); } }); // ─── Cross-department requests ──────────────────────────────────────── // An editor (master admin or any executive) asks another department for // something ("toilet paper restock") WITHOUT needing edit rights there: the // request lands as a normal task in that team's auto-created "Requests" inbox // list, attributed to the requester, optionally with photos. The target team // works it like any task — check it off, or an exec Moves it into a working // list. Send is deliberately append-only and editor-only (requireTeamAdmin). app.post('/api/requests', requireTeamAdmin, async (req, res) => { try { const title = String((req.body && req.body.title) || '').trim().slice(0, 120); if (!title) return res.status(400).json({ error: 'Say what you need' }); const details = String((req.body && req.body.details) || '').trim().slice(0, 2000); const wantLoc = normalizeName(String((req.body && req.body.location) || '')); const wantTeam = normalizeName(String((req.body && req.body.team) || '')); if (!wantLoc || !wantTeam) return res.status(400).json({ error: 'Pick a department' }); const state = await getState(); // The target must be a REAL department — a (location, team) pair that has a // team login or a list (the same pairs the send dropdown offers). Resolve the // canonical casing from the match so the inbox scopes identically to theirs. const pair = Object.values(state.users || {}).map((u) => ({ location: u.location || '', team: u.team || '' })) .concat((state.lists || []).map((l) => ({ location: l.location || '', team: l.team || '' }))) .find((p) => p.location.trim() && p.team.trim() && normalizeName(p.location) === wantLoc && normalizeName(p.team) === wantTeam); if (!pair) return res.status(400).json({ error: 'That department does not exist' }); let list = (state.lists || []).find((l) => l.isRequests && normalizeName(l.location || '') === wantLoc && normalizeName(l.team || '') === wantTeam); if (!list) { list = { id: makeListId('Requests', pair.location, pair.team, state), name: 'Requests', location: pair.location, team: pair.team, color: '', hidden: false, showNotes: false, notesOnly: false, showFeedback: false, feedback: [], taskGroups: sanitizeTaskGroups(null), columnOrder: sanitizeColumnOrder(null), resetSchedule: null, isRequests: true, items: [], lastCheckDate: new Date().toDateString() }; state.lists.push(list); } const item = { id: `${Date.now()}-${Math.random().toString(16).slice(2, 6)}`, title, description: details, kind: 'task', group: 'todo', recurrence: null, // requests never recur requestedBy: String(req.user.displayName || req.user.username || 'Executive').slice(0, 60), requestedAt: new Date().toISOString(), createdAt: new Date().toISOString() }; const images = normEquipImages(req.body && req.body.images).slice(0, 6); if (images.length) item.images = images; list.items.push(item); reconcileItemGroups(list); logActivity(state, req.user, 'request.create', 'requested "' + title + '" from ' + listLabel(list)); await saveState(state); res.json({ ok: true, listId: list.id, item }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to send the request' }); } }); // Completed requests archive themselves: a request is one-shot (no reset), so // its Done column would otherwise grow forever. 30 days after the target team's // scope marked one done, drop the item and its check records. Daily cron. async function pruneOldRequests() { try { const state = await getState(); const cutoff = Date.now() - 30 * 86400000; let removed = 0; (state.lists || []).forEach((list) => { if (!list.isRequests) return; const key = scopeKey(list.location, list.team); const rec = (key && state.checks && state.checks[key] && state.checks[key][list.id]) || {}; list.items = (list.items || []).filter((it) => { const c = rec[it.id]; const doneAt = c && c.completed && Date.parse(c.completedDate || ''); if (!(doneAt && doneAt < cutoff)) return true; removed++; Object.values(state.checks || {}).forEach((byList) => { if (byList && byList[list.id]) delete byList[list.id][it.id]; }); return false; }); }); if (removed) { console.log(`[requests] archived ${removed} completed request(s)`); await saveState(state); } } catch (err) { console.error('[requests] prune failed:', err.message); } } // ─── Feedback column (team input) ──────────────────────────────────── // Add an entry to a list's Feedback column. Open to ANYONE the list applies to // — the whole point is that normal checkers can leave input — using the same // scope + tap-your-name attribution rules as a check-off (resolveCheckScope). app.post('/api/lists/:id/feedback', requireUser, async (req, res) => { try { const text = String((req.body && req.body.text) || '').trim().slice(0, 2000); if (!text) return res.status(400).json({ error: 'Write something first' }); const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); const { allowed, by } = resolveCheckScope(state, req, list); if (!allowed) return res.status(403).json({ error: 'This list is not assigned to you' }); if (!Array.isArray(list.feedback)) list.feedback = []; const entry = { id: makeFeedbackId(), text, by, byUser: req.user.username, createdAt: new Date().toISOString() }; list.feedback.push(entry); logActivity(state, req.user, 'feedback.add', 'added feedback on ' + listLabel(list), by); await saveState(state); res.json({ ok: true, entry }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to add feedback' }); } }); // Who may change an existing entry: any editor who owns the list, or the team // login that wrote it (shared logins mean the TEAM owns its entries — per-person // attribution is display-only, like check-off attribution). function canTouchFeedback(user, list, entry) { if (ownsList(user, list)) return true; return user.role === 'user' && entry.byUser === user.username && listAppliesToScope(list, user.location, user.team); } app.put('/api/lists/:id/feedback/:fid', requireUser, async (req, res) => { try { const text = String((req.body && req.body.text) || '').trim().slice(0, 2000); if (!text) return res.status(400).json({ error: 'Write something first' }); const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); const entry = (list.feedback || []).find((f) => f.id === req.params.fid); if (!entry) return res.status(404).json({ error: 'Feedback entry not found' }); if (!canTouchFeedback(req.user, list, entry)) return res.status(403).json({ error: 'Not your entry' }); entry.text = text; entry.editedAt = new Date().toISOString(); logActivity(state, req.user, 'feedback.edit', 'edited feedback on ' + listLabel(list)); await saveState(state); res.json({ ok: true, entry }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to update feedback' }); } }); // Mass-delete: clear EVERY feedback entry on a list in one go. Editors only // (master admin, or an executive whose scope covers the list) — checkers can // only ever remove their own entries one at a time via the :fid route below. app.delete('/api/lists/:id/feedback', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); if (!ownsList(req.user, list)) return res.status(403).json({ error: 'Not your list' }); const n = Array.isArray(list.feedback) ? list.feedback.length : 0; list.feedback = []; logActivity(state, req.user, 'feedback.clear', 'cleared ' + n + ' feedback entr' + (n === 1 ? 'y' : 'ies') + ' on ' + listLabel(list)); await saveState(state); res.json({ ok: true, cleared: n }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to clear feedback' }); } }); app.delete('/api/lists/:id/feedback/:fid', requireUser, async (req, res) => { try { const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); const idx = (list.feedback || []).findIndex((f) => f.id === req.params.fid); if (idx < 0) return res.status(404).json({ error: 'Feedback entry not found' }); if (!canTouchFeedback(req.user, list, list.feedback[idx])) return res.status(403).json({ error: 'Not your entry' }); list.feedback.splice(idx, 1); logActivity(state, req.user, 'feedback.delete', 'deleted feedback on ' + listLabel(list)); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to delete feedback' }); } }); // ─── Team roster (for "tap your name" attribution) ────────────────── // A shared team login keeps a small list of volunteer names. Anyone on that // login can add their own name (self-service, no admin needed) or remove one. app.post('/api/roster/add', requireUser, async (req, res) => { try { if (req.user.role !== 'user') return res.status(403).json({ error: 'Team logins only' }); const name = String(req.body && req.body.name || '').trim().slice(0, 40); if (!name) return res.status(400).json({ error: 'Name required' }); const state = await getState(); const rec = findUserRecord(state, req.user); if (!rec.username) return res.status(404).json({ error: 'Login not found' }); rec.roster = Array.isArray(rec.roster) ? rec.roster : []; if (!rec.roster.some((n) => n.toLowerCase() === name.toLowerCase())) { rec.roster.push(name); rec.roster = rec.roster.slice(0, 60); // sane cap await saveState(state); } res.json({ ok: true, roster: rec.roster }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to add name' }); } }); app.post('/api/roster/remove', requireUser, async (req, res) => { try { if (req.user.role !== 'user') return res.status(403).json({ error: 'Team logins only' }); const name = String(req.body && req.body.name || '').trim(); const state = await getState(); const rec = findUserRecord(state, req.user); if (!rec.username) return res.status(404).json({ error: 'Login not found' }); rec.roster = (Array.isArray(rec.roster) ? rec.roster : []).filter((n) => n.toLowerCase() !== name.toLowerCase()); await saveState(state); res.json({ ok: true, roster: rec.roster }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to remove name' }); } }); // ─── Roster management for editors (master = all teams; exec = in-scope) ── function canManageRoster(user, rec) { if (!rec) return false; if (isMasterAdmin(user)) return true; if (user.role === 'executive') return execCanEditList(user, { location: rec.location, team: rec.team }); return false; } app.get('/api/admin/rosters', requireTeamAdmin, async (req, res) => { const state = await getState(); const teams = Object.values(state.users || {}) .filter((u) => (u.role || 'user') === 'user' && canManageRoster(req.user, u)) .map((u) => ({ username: u.username, location: u.location || '', team: u.team || '', displayName: u.displayName || u.username, roster: Array.isArray(u.roster) ? u.roster : [] })) .sort((a, b) => (a.location + a.team).localeCompare(b.location + b.team)); res.json({ teams }); }); app.post('/api/admin/rosters/add', requireTeamAdmin, async (req, res) => { try { const name = String(req.body && req.body.name || '').trim().slice(0, 40); if (!name) return res.status(400).json({ error: 'Name required' }); const state = await getState(); const rec = (state.users || {})[String(req.body && req.body.username || '')]; if (!rec || !canManageRoster(req.user, rec)) return res.status(403).json({ error: 'Not your team' }); rec.roster = Array.isArray(rec.roster) ? rec.roster : []; if (!rec.roster.some((n) => n.toLowerCase() === name.toLowerCase())) { rec.roster.push(name); rec.roster = rec.roster.slice(0, 60); logActivity(state, req.user, 'roster.add', 'added roster name "' + name + '" to ' + (rec.displayName || rec.username)); await saveState(state); } res.json({ ok: true, roster: rec.roster }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to add name' }); } }); app.post('/api/admin/rosters/remove', requireTeamAdmin, async (req, res) => { try { const name = String(req.body && req.body.name || '').trim(); const state = await getState(); const rec = (state.users || {})[String(req.body && req.body.username || '')]; if (!rec || !canManageRoster(req.user, rec)) return res.status(403).json({ error: 'Not your team' }); rec.roster = (Array.isArray(rec.roster) ? rec.roster : []).filter((n) => n.toLowerCase() !== name.toLowerCase()); logActivity(state, req.user, 'roster.remove', 'removed roster name "' + name + '" from ' + (rec.displayName || rec.username)); await saveState(state); res.json({ ok: true, roster: rec.roster }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to remove name' }); } }); // Admin audit log — master only. Newest first, capped for the UI. app.get('/api/admin/activity', requireMasterAdmin, async (req, res) => { try { const state = await getState(); const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 300)); const activity = (state.activity || []).slice(-limit).reverse(); res.json({ activity }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to load activity' }); } }); // Admin: overwrite lists + checks (used for list management & import) app.post('/api/admin/state', requireMasterAdmin, async (req, res) => { try { const incoming = req.body?.state; if (!incoming || !Array.isArray(incoming.lists)) { return res.status(400).json({ error: 'Invalid state payload' }); } const current = await getState(); // Don't accept users via the bulk-state endpoint — they go through /api/admin/users. // Sanitize the per-list settings on ingest (this path also backs Import JSON), so a // malformed color/hidden/resetSchedule can't be stored and silently misbehave. const prevById = new Map((current.lists || []).map((p) => [p.id, p])); current.lists = incoming.lists.map((l) => { const out = Object.assign({}, l, { color: sanitizeColor(l && l.color), hidden: !!(l && l.hidden), showNotes: !!(l && (l.showNotes || l.notesOnly)), notesOnly: !!(l && l.notesOnly), showFeedback: !!(l && l.showFeedback), feedback: cleanFeedback(l && l.feedback), taskGroups: sanitizeTaskGroups(l && l.taskGroups), isRequests: !!(l && l.isRequests), columnOrder: sanitizeColumnOrder(l && l.columnOrder), resetSchedule: (l && l.isRequests) ? null : sanitizeResetSchedule(l && l.resetSchedule) }); // Sanitize each item's recurrence too (the master's full-state push is the only // write path that reaches items in bulk) — so an every-N recurrence gets its // anchor set and intraday times are validated, exactly like the scoped endpoints. if (Array.isArray(out.items)) { out.items = out.items.map((it) => { if (!it || typeof it !== 'object') return it; const kind = it.kind === 'note' ? 'note' : 'task'; // normalize (migrateState is cached; won't touch imports) return Object.assign({}, it, { kind, recurrence: kind === 'note' ? null : sanitizeRecurrence(it.recurrence), ...(typeof it.description === 'string' ? { description: stripEmptyTaskLines(it.description) } : {}) }); }); } reconcileItemGroups(out); // bulk edits may add/remove sections — snap orphaned tasks to default // lastResetSlot is server-owned: re-anchor it from prior server state, never // from the incoming snapshot (an undo could otherwise replay a stale slot and // wipe live checks). reconcileResetSlot(out, prevById.get(out.id)); return out; }); current.selectedListId = incoming.selectedListId || current.selectedListId; if (incoming.checks && typeof incoming.checks === 'object') current.checks = incoming.checks; // Absorb any legacy structured subtasks in the pushed snapshot into descriptions + // remap id-keyed checks (covers importing a pre-pivot full backup through this path). migrateLegacySubtasks(current); // A bulk edit may have changed a task's checkbox lines → realign every location's // per-index progress by matching labels (compare each item's description signature // against its prior server state; a prose-only or unchanged item is left alone). current.lists.forEach((l) => { const prevList = prevById.get(l.id); const prevItems = new Map(((prevList && prevList.items) || []).map((p) => [p.id, p])); (l.items || []).forEach((it) => { const prev = prevItems.get(it.id); if (prev && taskSignature(prev.description) !== taskSignature(it.description)) { remapItemSubChecks(current, l.id, it, prev.description); } }); }); logActivity(current, req.user, 'lists.save', 'saved list changes (bulk edit, ' + current.lists.length + ' list' + (current.lists.length === 1 ? '' : 's') + ')'); await saveState(current); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to save state' }); } }); // Scoped import: restore/add lists from an export file, but ONLY lists the // caller may manage (master = any; executive = within their location/team scope). // Never touches other teams' lists, equipment, users, directory, or live checks. // Upserts by id, so re-importing the same backup refreshes rather than duplicates. app.post('/api/import-lists', requireTeamAdmin, async (req, res) => { try { const incoming = Array.isArray(req.body && req.body.lists) ? req.body.lists : null; if (!incoming) return res.status(400).json({ error: 'No lists found in that file' }); const state = await getState(); state.lists = state.lists || []; let imported = 0, skipped = 0; incoming.forEach((raw) => { if (!raw || typeof raw !== 'object') { skipped++; return; } const loc = String(raw.location || '').trim(); const team = String(raw.team || '').trim(); // The caller must be allowed to manage a list at this location/team. if (!(isMasterAdmin(req.user) || execCanEditList(req.user, { location: loc, team }))) { skipped++; return; } const name = String(raw.name || '').trim().slice(0, 120) || 'Imported'; const items = (Array.isArray(raw.items) ? raw.items : []) .map((it) => buildItem({ title: it && it.title, description: it && it.description, recurrence: it && it.recurrence, subtasks: it && it.subtasks, kind: it && it.kind, group: it && it.group })) .filter(Boolean); const existing = raw.id ? state.lists.find((l) => l.id === raw.id) : null; if (existing) { // Only refresh a list the caller already owns — never hijack another team's id. if (!ownsList(req.user, existing)) { skipped++; return; } const prevSnap = { resetSchedule: existing.resetSchedule, lastResetSlot: existing.lastResetSlot }; existing.name = name; existing.items = items; existing.color = sanitizeColor(raw.color); existing.hidden = !!raw.hidden; existing.showNotes = !!(raw.showNotes || raw.notesOnly); existing.notesOnly = !!raw.notesOnly; existing.showFeedback = !!raw.showFeedback; // Restore feedback only when the file HAS it — an older export (no feedback // field) must not wipe the live entries on a refresh-import. if (Array.isArray(raw.feedback)) existing.feedback = cleanFeedback(raw.feedback); existing.taskGroups = sanitizeTaskGroups(raw.taskGroups); reconcileItemGroups(existing); existing.isRequests = !!raw.isRequests; existing.columnOrder = sanitizeColumnOrder(raw.columnOrder); existing.resetSchedule = raw.isRequests ? null : sanitizeResetSchedule(raw.resetSchedule); reconcileResetSlot(existing, prevSnap); // server-owned slot; don't trust imported value imported++; } else { const created = { id: makeListId(name, loc, team, state), name, location: loc, team, color: sanitizeColor(raw.color), hidden: !!raw.hidden, showNotes: !!(raw.showNotes || raw.notesOnly), notesOnly: !!raw.notesOnly, showFeedback: !!raw.showFeedback, feedback: cleanFeedback(raw.feedback), taskGroups: sanitizeTaskGroups(raw.taskGroups), isRequests: !!raw.isRequests, columnOrder: sanitizeColumnOrder(raw.columnOrder), resetSchedule: raw.isRequests ? null : sanitizeResetSchedule(raw.resetSchedule), items, lastCheckDate: new Date().toDateString() }; reconcileItemGroups(created); reconcileResetSlot(created, null); state.lists.push(created); imported++; } }); if (imported) logActivity(state, req.user, 'import', 'imported ' + imported + ' list' + (imported === 1 ? '' : 's') + (skipped ? ' (' + skipped + ' skipped)' : '')); await saveState(state); res.json({ ok: true, imported, skipped }); } catch (err) { console.error(err); res.status(500).json({ error: 'Import failed' }); } }); // Undo/redo restore for editors (executives + team admins). Applies a list of // id-TARGETED ops — each op sets one list to a given snapshot (`set`) or deletes // it (`set: null`). ONLY the list ids named in the ops are touched, so an undo // reverts exactly the list(s) the editor's own action changed and CANNOT delete // or overwrite a list a concurrent editor changed in overlapping scope. Bounded by // ownsList() (caller must own the target id AND the snapshot's scope), so it grants // no privilege beyond the granular endpoints. Never touches non-owned lists, // equipment, users, the directory, or the live checks map. app.post('/api/scoped-restore', requireTeamAdmin, async (req, res) => { try { const ops = Array.isArray(req.body && req.body.ops) ? req.body.ops : null; if (!ops) return res.status(400).json({ error: 'Invalid payload' }); const state = await getState(); state.lists = state.lists || []; const cleanItem = (it) => { if (!it || typeof it !== 'object') return null; const title = String(it.title || '').trim(); if (!title) return null; const kind = it.kind === 'note' ? 'note' : 'task'; return { id: String(it.id || `${Date.now()}-${Math.random().toString(16).slice(2, 6)}`), title, description: String(it.description || '').trim(), kind, ...(kind === 'note' ? {} : { group: String(it.group || 'todo') }), // undo must not strand tasks ...(it.requestedBy ? { requestedBy: String(it.requestedBy).slice(0, 60), requestedAt: String(it.requestedAt || '').slice(0, 40) } : {}), ...(Array.isArray(it.images) && it.images.length ? { images: normEquipImages(it.images) } : {}), recurrence: kind === 'note' ? null : sanitizeRecurrence(it.recurrence), createdAt: it.createdAt || new Date().toISOString() }; }; const cleanList = (raw) => ({ id: String(raw.id), name: String(raw.name || '').trim().slice(0, 120) || 'List', location: String(raw.location || '').trim(), team: String(raw.team || '').trim(), color: sanitizeColor(raw.color), hidden: !!raw.hidden, showNotes: !!(raw.showNotes || raw.notesOnly), notesOnly: !!raw.notesOnly, showFeedback: !!raw.showFeedback, feedback: cleanFeedback(raw.feedback), // snapshots carry feedback — an undo must not wipe it taskGroups: sanitizeTaskGroups(raw.taskGroups), // snapshots carry sections too isRequests: !!raw.isRequests, columnOrder: sanitizeColumnOrder(raw.columnOrder), resetSchedule: raw.isRequests ? null : sanitizeResetSchedule(raw.resetSchedule), items: (Array.isArray(raw.items) ? raw.items : []).map(cleanItem).filter(Boolean), lastCheckDate: raw.lastCheckDate || new Date().toDateString() }); const undo = { del: 0, edit: 0, add: 0, labels: [] }; for (const op of ops) { if (!op || !op.id) return res.status(400).json({ error: 'Invalid op' }); const id = String(op.id); const idx = state.lists.findIndex((l) => l.id === id); const existing = idx >= 0 ? state.lists[idx] : null; if (op.set === null || op.set === undefined) { // DELETE this id — only if it currently exists AND the caller owns it. if (existing && ownsList(req.user, existing)) { undo.del++; undo.labels.push(listLabel(existing)); state.lists.splice(idx, 1); } } else { // UPSERT — caller must own the snapshot's scope, and (if replacing) the existing list. const next = cleanList(op.set); if (!ownsList(req.user, { location: next.location, team: next.team })) { return res.status(403).json({ error: 'A list in the snapshot is outside your scope' }); } if (existing) { if (!ownsList(req.user, existing)) return res.status(403).json({ error: 'Cannot overwrite a list outside your scope' }); state.lists[idx] = next; // in-place (preserves global position) undo.edit++; undo.labels.push(listLabel(next)); } else { state.lists.push(next); // re-add (e.g. undo of a delete), original id preserved undo.add++; undo.labels.push(listLabel(next)); } } } // Audit the undo/redo too — it can delete/overwrite lists, so it must leave a trail. if (undo.del || undo.edit || undo.add) { const bits = []; if (undo.del) bits.push('deleted ' + undo.del); if (undo.edit) bits.push('changed ' + undo.edit); if (undo.add) bits.push('restored ' + undo.add); const names = undo.labels.slice(0, 4).join(', ') + (undo.labels.length > 4 ? ', …' : ''); logActivity(state, req.user, 'list.restore', 'undo/redo — ' + bits.join(', ') + ' list(s)' + (names ? ': ' + names : '')); } await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Restore failed' }); } }); // ─── Scoped list/item editing (team admins) ───────────────────── // Granular, ownership-checked endpoints. A team admin can only touch lists in // its own (location, team); the master admin can touch any. By construction a // team admin has no endpoint that can write another team's data. app.post('/api/lists', requireTeamAdmin, async (req, res) => { try { const name = String(req.body?.name || '').trim(); if (!name) return res.status(400).json({ error: 'List name required' }); const state = await getState(); // Master may place a list anywhere; an executive picks location/team but it // must land inside their scope; a team admin's list is forced into its scope. let loc, tm; if (isMasterAdmin(req.user)) { loc = String(req.body?.location || '').trim(); tm = String(req.body?.team || '').trim(); } else if (req.user.role === 'executive') { loc = String(req.body?.location || '').trim(); tm = String(req.body?.team || '').trim(); if (!execCanEditList(req.user, { location: loc, team: tm })) { return res.status(403).json({ error: 'That location/team is outside your area' }); } } else { loc = req.user.location || ''; tm = req.user.team || ''; } const id = makeListId(name, loc, tm, state); // Optional starter tasks (e.g. created "from a template"): each is rebuilt with // a fresh id, so no checks or stale ids carry over. const items = Array.isArray(req.body?.items) ? req.body.items.map(buildItem).filter(Boolean).slice(0, 500) : []; const created = { id, name, location: loc, team: tm, color: sanitizeColor(req.body?.color), hidden: false, showNotes: !!(req.body?.showNotes || req.body?.notesOnly), notesOnly: !!req.body?.notesOnly, showFeedback: !!req.body?.showFeedback, feedback: [], taskGroups: sanitizeTaskGroups(req.body?.taskGroups), columnOrder: sanitizeColumnOrder(req.body?.columnOrder), resetSchedule: sanitizeResetSchedule(req.body?.resetSchedule), items, lastCheckDate: new Date().toDateString() }; reconcileItemGroups(created); reconcileResetSlot(created, null); // anchor intraday slot at creation time state.lists.push(created); logActivity(state, req.user, 'list.create', 'created list ' + listLabel({ name, location: loc, team: tm })); await saveState(state); res.json({ ok: true, id }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to create list' }); } }); app.put('/api/lists/:id', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); if (!ownsList(req.user, list)) return res.status(403).json({ error: 'Not your list' }); if (typeof req.body?.name === 'string' && req.body.name.trim()) list.name = req.body.name.trim(); // Any owner (incl. executives) may recolor, hide/show, or schedule resets on their list. if ('color' in (req.body || {})) list.color = sanitizeColor(req.body.color); if ('hidden' in (req.body || {})) list.hidden = !!req.body.hidden; // Enable/disable the Notes column, and the shared column order. This one PUT serves both the // list-modal "Enable Notes" toggle and the lightweight reorder-chevron ({columnOrder} only). if ('showNotes' in (req.body || {})) list.showNotes = !!req.body.showNotes; if ('notesOnly' in (req.body || {})) { list.notesOnly = !!req.body.notesOnly; if (list.notesOnly) list.showNotes = true; } if ('showFeedback' in (req.body || {})) list.showFeedback = !!req.body.showFeedback; // Task sections: replace the set; tasks in a removed section snap back to the default. if ('taskGroups' in (req.body || {})) { list.taskGroups = sanitizeTaskGroups(req.body.taskGroups); reconcileItemGroups(list); } if ('columnOrder' in (req.body || {})) list.columnOrder = sanitizeColumnOrder(req.body.columnOrder); if ('resetSchedule' in (req.body || {}) && !list.isRequests) { // a Requests inbox is one-shot — never schedulable const prevSnap = { resetSchedule: list.resetSchedule, lastResetSlot: list.lastResetSlot }; list.resetSchedule = sanitizeResetSchedule(req.body.resetSchedule); reconcileResetSlot(list, prevSnap); // changing the times re-anchors the slot so the edit doesn't wipe mid-day } if (list.isRequests) list.resetSchedule = null; // belt-and-braces for any earlier write path // Only the master admin may re-scope a list to another location/team. if (isMasterAdmin(req.user)) { if (typeof req.body?.location === 'string') list.location = req.body.location.trim(); if (typeof req.body?.team === 'string') list.team = req.body.team.trim(); } logActivity(state, req.user, 'list.edit', 'edited list ' + listLabel(list)); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to update list' }); } }); // Manual reset: move every item in this list back to "To Do" by clearing its // checks across ALL scope keys (only this list is affected). Lets the same list // be reused for any event on any date. app.post('/api/lists/:id/reset', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); if (!ownsList(req.user, list)) return res.status(403).json({ error: 'Not your list' }); Object.keys(state.checks || {}).forEach((scope) => { if (state.checks[scope] && state.checks[scope][list.id]) delete state.checks[scope][list.id]; }); list.lastCheckDate = new Date().toDateString(); logActivity(state, req.user, 'list.reset', 'reset list ' + listLabel(list) + ' (all items back to To Do)'); await saveState(state); res.json({ ok: true, checks: state.checks }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to reset list' }); } }); // Duplicate a list (master + any editor who owns it). Copies name "(copy)", scope, // color, hidden, schedule, and all items with FRESH ids — so no checks carry over. app.post('/api/lists/:id/duplicate', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const src = state.lists.find((l) => l.id === req.params.id); if (!src) return res.status(404).json({ error: 'List not found' }); if (!ownsList(req.user, src)) return res.status(403).json({ error: 'Not your list' }); const name = ((src.name || 'List') + ' (copy)').slice(0, 120); const id = makeListId(name, src.location, src.team, state); const copy = { id, name, location: src.location || '', team: src.team || '', color: sanitizeColor(src.color), hidden: !!src.hidden, showNotes: !!src.showNotes, notesOnly: !!src.notesOnly, showFeedback: !!src.showFeedback, feedback: [], // a duplicate is a template — team input stays with the original taskGroups: sanitizeTaskGroups(src.taskGroups), columnOrder: sanitizeColumnOrder(src.columnOrder), resetSchedule: sanitizeResetSchedule(src.resetSchedule), items: (src.items || []).map((it, i) => { const kind = it.kind === 'note' ? 'note' : 'task'; return { id: `${Date.now()}-${i}-${Math.random().toString(16).slice(2, 6)}`, title: String(it.title || ''), description: String(it.description || ''), kind, ...(kind === 'note' ? {} : { group: String(it.group || 'todo') }), recurrence: kind === 'note' ? null : sanitizeRecurrence(it.recurrence), createdAt: new Date().toISOString() }; }), lastCheckDate: new Date().toDateString() }; reconcileItemGroups(copy); reconcileResetSlot(copy, null); // fresh copy: anchor its own intraday slot const idx = state.lists.findIndex((l) => l.id === src.id); state.lists.splice(idx + 1, 0, copy); // insert right after the original logActivity(state, req.user, 'list.duplicate', 'duplicated list ' + listLabel(src) + ' → "' + copy.name + '"'); await saveState(state); res.json({ ok: true, id }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to duplicate list' }); } }); app.delete('/api/lists/:id', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const idx = state.lists.findIndex((l) => l.id === req.params.id); if (idx === -1) return res.status(404).json({ error: 'List not found' }); if (!ownsList(req.user, state.lists[idx])) return res.status(403).json({ error: 'Not your list' }); const deletedLabel = listLabel(state.lists[idx]); const deletedCount = (state.lists[idx].items || []).length; state.lists.splice(idx, 1); logActivity(state, req.user, 'list.delete', 'deleted list ' + deletedLabel + ' (' + deletedCount + ' task' + (deletedCount === 1 ? '' : 's') + ')'); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to delete list' }); } }); app.post('/api/lists/:id/items', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); if (!ownsList(req.user, list)) return res.status(403).json({ error: 'Not your list' }); const item = buildItem(req.body); if (!item) return res.status(400).json({ error: 'Task title required' }); list.items.push(item); reconcileItemGroups(list); // snap the new task's section to one the list actually has if (item.kind === 'note' && !list.showNotes) list.showNotes = true; // adding a note reveals the Notes column logActivity(state, req.user, 'item.create', 'added ' + (item.kind === 'note' ? 'note' : 'task') + ' "' + (item.title || '?') + '" to ' + listLabel(list)); await saveState(state); res.json({ ok: true, item }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to add task' }); } }); app.put('/api/lists/:id/items/:itemId', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); if (!ownsList(req.user, list)) return res.status(403).json({ error: 'Not your list' }); const item = list.items.find((i) => i.id === req.params.itemId); if (!item) return res.status(404).json({ error: 'Task not found' }); if (typeof req.body?.title === 'string') { const t = req.body.title.trim(); if (!t) return res.status(400).json({ error: 'Task title required' }); item.title = t; } if (typeof req.body?.description === 'string') { const prevDesc = item.description; item.description = stripEmptyTaskLines(req.body.description.trim()); // drop ghost empty checkboxes // If the checkbox lines changed (added/removed/reordered/renamed), realign every // location's per-index progress by matching labels — a prose-only edit leaves it intact. if (taskSignature(prevDesc) !== taskSignature(item.description)) { remapItemSubChecks(state, list.id, item, prevDesc); } } // A note never recurs; ignore any recurrence sent for one (conversion is forbidden, so kind here is // whatever the item already is — we never change it on edit). if ('recurrence' in (req.body || {})) item.recurrence = item.kind === 'note' ? null : sanitizeRecurrence(req.body.recurrence); // Move between task sections (notes never carry a group; unknown ids snap to default). if (typeof req.body?.group === 'string' && item.kind !== 'note') { item.group = req.body.group.trim().toLowerCase().slice(0, 40) || 'todo'; reconcileItemGroups(list); } logActivity(state, req.user, 'item.edit', 'edited task "' + (item.title || '?') + '" in ' + listLabel(list)); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to update task' }); } }); app.delete('/api/lists/:id/items/:itemId', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); if (!ownsList(req.user, list)) return res.status(403).json({ error: 'Not your list' }); const removed = list.items.find((i) => i.id === req.params.itemId); list.items = list.items.filter((i) => i.id !== req.params.itemId); if (removed) logActivity(state, req.user, 'item.delete', 'deleted task "' + (removed.title || '?') + '" from ' + listLabel(list)); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to delete task' }); } }); app.post('/api/lists/:id/items/:itemId/move', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const src = state.lists.find((l) => l.id === req.params.id); const dst = state.lists.find((l) => l.id === (req.body && req.body.targetListId)); if (!src || !dst) return res.status(404).json({ error: 'List not found' }); // Must own BOTH ends — a team admin can't fling tasks into another team. if (!ownsList(req.user, src) || !ownsList(req.user, dst)) return res.status(403).json({ error: 'Not your list' }); const idx = src.items.findIndex((i) => i.id === req.params.itemId); if (idx === -1) return res.status(404).json({ error: 'Task not found' }); const [moved] = src.items.splice(idx, 1); dst.items.push(moved); reconcileItemGroups(dst); // the target list may not have the source's section → default logActivity(state, req.user, 'item.move', 'moved task "' + (moved.title || '?') + '" from ' + listLabel(src) + ' to ' + listLabel(dst)); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to move task' }); } }); // Reorder the tasks WITHIN one owned list (drag-reorder). Body: { order: [itemId, ...] }. // Only the ORDER of existing items changes — never their content, never checks, never list // membership. Reconciles against the live list: any item not named in `order` (e.g. one a // co-owner added concurrently) is appended, so a stale client order can't drop a task. app.post('/api/lists/:id/items/reorder', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const list = state.lists.find((l) => l.id === req.params.id); if (!list) return res.status(404).json({ error: 'List not found' }); if (!ownsList(req.user, list)) return res.status(403).json({ error: 'Not your list' }); const order = Array.isArray(req.body && req.body.order) ? req.body.order : []; const byId = new Map(list.items.map((it) => [it.id, it])); const seen = new Set(); const reordered = []; for (const id of order) { // place the items the client listed, in that order const it = byId.get(id); if (it && !seen.has(id)) { reordered.push(it); seen.add(id); } } for (const it of list.items) { // keep anything not mentioned — never drop a task if (!seen.has(it.id)) reordered.push(it); } list.items = reordered; logActivity(state, req.user, 'item.reorder', 'reordered tasks in ' + listLabel(list)); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to reorder tasks' }); } }); // Reorder LISTS (drag-reorder in card + tree view). Body: { order: [listId, ...] }. // Only lists the caller OWNS can be repositioned, and they're permuted WITHIN the // array slots they already occupy — so a caller can never move another team's lists // and no other list ever shifts position. Any id in `order` that doesn't exist or // isn't owned is ignored (a client can't reorder what it can't edit). This lets // executives and team admins reorder their own lists, while master (who owns all) // gets a full reorder — its undoable full-state path is used client-side instead. app.post('/api/lists/reorder', requireTeamAdmin, async (req, res) => { try { const order = Array.isArray(req.body && req.body.order) ? req.body.order.map(String) : []; const state = await getState(); const lists = state.lists || []; const byId = new Map(lists.map((l) => [l.id, l])); const seen = new Set(); const ownedOrder = []; for (const id of order) { if (seen.has(id)) continue; const l = byId.get(id); if (l && ownsList(req.user, l)) { ownedOrder.push(id); seen.add(id); } } if (ownedOrder.length < 2) return res.json({ ok: true }); // nothing meaningful to reorder // Slots currently occupied by the owned lists, in ascending array position. const idSet = new Set(ownedOrder); const slots = []; lists.forEach((l, i) => { if (idSet.has(l.id)) slots.push(i); }); const ordered = ownedOrder.map((id) => byId.get(id)); slots.forEach((slotIdx, k) => { lists[slotIdx] = ordered[k]; }); // drop them back in the new order logActivity(state, req.user, 'list.reorder', 'reordered lists'); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to reorder lists' }); } }); // Any editor sends the report NOW for THEIR area (master = global + all teams; // executive = the teams within their scope; team-admin = their own team). Never resets. app.post('/api/send-report', requireTeamAdmin, async (req, res) => { try { const result = await sendScopedReport(req.user, { dryRun: !!(req.body && req.body.dryRun) }); res.json(result); } catch (err) { console.error(err); res.status(500).json({ error: err.message || 'Failed to send report' }); } }); // Admin: preview the digest body without sending (no SMTP required). app.get('/api/admin/digest-preview', requireMasterAdmin, async (req, res) => { try { const state = await getState(); const global = buildReport(state); const teams = teamsWithRecipients(state).map((acct) => { const r = buildTeamReport(state, acct); return { team: teamLabel(acct), recipients: acct.emailRecipients, totalDone: r.totalDone, totalMissed: r.totalMissed, text: r.text, html: r.html }; }); res.json({ global: { text: global.text, html: global.html, totalDone: global.totalDone, totalMissed: global.totalMissed }, teams }); } catch (err) { console.error(err); res.status(500).json({ error: err.message || 'Failed to build preview' }); } }); // Admin: user management app.get('/api/admin/users', requireMasterAdmin, async (req, res) => { const state = await getState(); const users = Object.values(state.users || {}).map((u) => ({ username: u.username, displayName: u.displayName, location: u.location || '', team: u.team || '', role: u.role || 'user', emailRecipients: Array.isArray(u.emailRecipients) ? u.emailRecipients : [], createdAt: u.createdAt })); res.json({ users }); }); app.post('/api/admin/users', requireMasterAdmin, async (req, res) => { try { const { username, password, displayName, location, team, role, emailRecipients } = req.body || {}; const loc = (location || '').trim(); const tm = (team || '').trim(); // Username is the canonical id. Use the one provided, else derive a stable // slug from location + team (e.g. "South Coast" + "Production" -> "south-coast.production"). const u = (String(username || '').trim().toLowerCase()) || makeUsername(loc, tm); const pw = String(password || ''); if (!u || !pw) return res.status(400).json({ error: 'Username and password required' }); if (u === 'admin') return res.status(400).json({ error: '"admin" is reserved' }); if (!/^[a-z0-9._-]{2,40}$/.test(u)) return res.status(400).json({ error: 'Username can only contain letters, numbers, dots, dashes, and underscores (2-40 chars)' }); if (pw.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' }); const state = await getState(); state.users = state.users || {}; if (state.users[u]) return res.status(409).json({ error: 'That username is already taken' }); // One shared login per (location, team). if (loc || tm) { const dup = Object.values(state.users).find((x) => normalizeName(x.location || '') === normalizeName(loc) && ((x.team || '').trim() ? normalizeName(x.team) : '') === (tm ? normalizeName(tm) : '')); if (dup) return res.status(409).json({ error: 'A login for that location + team already exists' }); } state.users[u] = { username: u, displayName: (displayName || '').trim() || (tm ? `${normalizeName(loc)} · ${normalizeName(tm)}` : (loc ? normalizeName(loc) : u)), passwordHash: await bcrypt.hash(pw, 10), location: loc, team: tm, role: role === 'admin' ? 'admin' : 'user', emailRecipients: normalizeRecipients(emailRecipients), createdAt: new Date().toISOString() }; registerDirectory(state, loc, tm); logActivity(state, req.user, 'user.create', 'created login "' + state.users[u].displayName + '" (' + state.users[u].role + ')'); await saveState(state); res.json({ ok: true, user: publicUser(state.users[u]) }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to create user' }); } }); app.put('/api/admin/users/:username', requireMasterAdmin, async (req, res) => { try { const u = String(req.params.username || '').trim().toLowerCase(); if (u === 'admin') return res.status(400).json({ error: '"admin" is managed via ADMIN_PASSWORD env var' }); const state = await getState(); const target = state.users && state.users[u]; if (!target) return res.status(404).json({ error: 'User not found' }); const { password, displayName, location, team, role, emailRecipients } = req.body || {}; const newLoc = typeof location === 'string' ? location.trim() : (target.location || ''); const newTeam = typeof team === 'string' ? team.trim() : (target.team || ''); const scopeChanged = newLoc !== (target.location || '') || newTeam !== (target.team || ''); // One shared login per (location, team) — block collisions with another account. if (scopeChanged && (newLoc || newTeam)) { const dup = Object.values(state.users).find((x) => x.username !== u && normalizeName(x.location || '') === normalizeName(newLoc) && ((x.team || '').trim() ? normalizeName(x.team) : '') === (newTeam ? normalizeName(newTeam) : '')); if (dup) return res.status(409).json({ error: 'A login for that location + team already exists' }); } const hadExplicitName = typeof displayName === 'string' && !!displayName.trim(); if (typeof displayName === 'string') target.displayName = displayName.trim() || target.username; target.location = newLoc; target.team = newTeam; if (role === 'admin' || role === 'user') target.role = role; if (emailRecipients !== undefined) target.emailRecipients = normalizeRecipients(emailRecipients); // Keep the auto label in sync with location/team unless an explicit name was given. if (scopeChanged && !hadExplicitName) { target.displayName = newTeam ? `${normalizeName(newLoc)} · ${normalizeName(newTeam)}` : (newLoc ? normalizeName(newLoc) : target.username); } if (scopeChanged) registerDirectory(state, newLoc, newTeam); if (typeof password === 'string' && password.length > 0) { if (password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' }); target.passwordHash = await bcrypt.hash(password, 10); } // Password OR scope change invalidates this login's active sessions so the new // credentials / scope (scopeKey) take effect on next sign-in. if (scopeChanged || (typeof password === 'string' && password.length > 0)) { for (const [tok, sess] of sessions.entries()) { if (sess.user && sess.user.username === u) sessions.delete(tok); } } logActivity(state, req.user, 'user.edit', 'edited login "' + target.displayName + '"' + ((typeof password === 'string' && password.length > 0) ? ' (password reset)' : '')); await saveState(state); res.json({ ok: true, user: publicUser(target) }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to update user' }); } }); app.delete('/api/admin/users/:username', requireMasterAdmin, async (req, res) => { try { const u = String(req.params.username || '').trim().toLowerCase(); if (u === 'admin') return res.status(400).json({ error: 'Cannot delete the master admin' }); const state = await getState(); if (!state.users || !state.users[u]) return res.status(404).json({ error: 'User not found' }); const deletedUserLabel = state.users[u].displayName || u; delete state.users[u]; for (const [tok, sess] of sessions.entries()) { if (sess.user && sess.user.username === u) sessions.delete(tok); } logActivity(state, req.user, 'user.delete', 'deleted login "' + deletedUserLabel + '"'); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to delete user' }); } }); // Admin: edit the location/team directory (names + login abbreviations). app.put('/api/admin/directory', requireMasterAdmin, async (req, res) => { try { const incoming = req.body || {}; const norm = (arr) => (Array.isArray(arr) ? arr : []) .map((e) => ({ name: String((e && e.name) || '').trim(), abbrev: String((e && e.abbrev) || '').trim().toLowerCase() })) .filter((e) => e.name); const state = await getState(); state.directory = { locations: norm(incoming.locations), teams: norm(incoming.teams) }; // Deleting a directory entry cascades to executive scopes (like rename does): // scrub grants that now point at nothing, and drop those execs' sessions so // their live scope refreshes on next sign-in. const scrubbed = scrubExecScopes(state); if (scrubbed.length) { const ids = new Set(scrubbed.map((e) => 'exec:' + e.id)); for (const [tok, sess] of sessions.entries()) { if (sess.user && ids.has(sess.user.username)) sessions.delete(tok); } logActivity(state, req.user, 'directory.edit', 'scope cleanup: ' + scrubbed.map((e) => e.name).join(', ')); } logActivity(state, req.user, 'directory.edit', 'updated locations & teams'); await saveState(state); res.json({ ok: true, directory: state.directory }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to save directory' }); } }); // Admin: rename a location or team IN PLACE, cascading the new name to every // list, team login, executive scope, and checks key that referenced the old one // — so nothing orphans. (Checks are weekly-reset, so this is best-effort but // kept correct via scopeKey.) Sessions referencing the old name are dropped so // the rename takes effect on next sign-in. app.post('/api/admin/directory/rename', requireMasterAdmin, async (req, res) => { try { const kind = String((req.body && req.body.kind) || '').trim(); const fromRaw = String((req.body && req.body.from) || '').trim(); const toRaw = String((req.body && req.body.to) || '').trim(); if (kind !== 'location' && kind !== 'team') return res.status(400).json({ error: 'kind must be "location" or "team"' }); if (!fromRaw || !toRaw) return res.status(400).json({ error: 'Both the current and new name are required' }); const from = normalizeName(fromRaw); const to = normalizeName(toRaw); const state = await getState(); state.directory = state.directory || { locations: [], teams: [] }; const arr = kind === 'team' ? (state.directory.teams || []) : (state.directory.locations || []); const entry = arr.find((e) => normalizeName(e.name) === from); if (!entry) return res.status(404).json({ error: 'That name is not in the directory' }); if (from === to) return res.json({ ok: true, directory: state.directory }); // no-op (same name) // Refuse to merge into a different existing entry. if (arr.some((e) => e !== entry && normalizeName(e.name) === to)) { return res.status(409).json({ error: `"${to}" already exists` }); } const isLoc = kind === 'location'; // 1) Directory entry itself. entry.name = to; // 2) Lists. (state.lists || []).forEach((l) => { if (isLoc) { if ((l.location || '').trim() && normalizeName(l.location) === from) l.location = to; } else { if ((l.team || '').trim() && normalizeName(l.team) === from) l.team = to; } }); // 3) Team logins. Object.values(state.users || {}).forEach((usr) => { if (isLoc) { if ((usr.location || '').trim() && normalizeName(usr.location) === from) usr.location = to; } else { if ((usr.team || '').trim() && normalizeName(usr.team) === from) usr.team = to; } }); // 4) Executive scopes. Object.values(state.executives || {}).forEach((e) => { const key = isLoc ? 'scopeLocations' : 'scopeTeams'; if (Array.isArray(e[key])) e[key] = e[key].map((s) => (normalizeName(s) === from ? to : s)); }); // 5) Checks keys ("Location" or "Location::Team"). Remap + merge on collision. const oldChecks = state.checks || {}; const newChecks = {}; Object.keys(oldChecks).forEach((k) => { const parts = splitScope(k); let loc = parts.location; let tm = parts.team; if (isLoc) { if (loc && normalizeName(loc) === from) loc = to; } else { if (tm && normalizeName(tm) === from) tm = to; } const nk = scopeKey(loc, tm) || k; newChecks[nk] = Object.assign({}, newChecks[nk] || {}, oldChecks[k]); }); state.checks = newChecks; // Drop sessions that referenced the old name so their scope/scopeKey refresh // on next sign-in (master has no location/team/scope, so it stays signed in). for (const [tok, sess] of sessions.entries()) { const su = sess.user || {}; const scopeArr = isLoc ? su.scopeLocations : su.scopeTeams; const onAxis = isLoc ? su.location : su.team; const hit = ((onAxis || '').trim() && normalizeName(onAxis) === from) || (Array.isArray(scopeArr) && scopeArr.some((s) => normalizeName(s) === from)); if (hit) sessions.delete(tok); } logActivity(state, req.user, 'directory.rename', 'renamed ' + kind + ' "' + fromRaw + '" → "' + toRaw + '"'); await saveState(state); res.json({ ok: true, directory: state.directory }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to rename' }); } }); // ─── Executives (named scoped editors; managed by the owner only) ─── function publicExec(e) { return { id: e.id, name: e.name, scopeLocations: Array.isArray(e.scopeLocations) ? e.scopeLocations : [], scopeTeams: Array.isArray(e.scopeTeams) ? e.scopeTeams : [], createdAt: e.createdAt }; } function normalizeScopeList(arr) { if (!Array.isArray(arr)) return []; const seen = new Set(); const out = []; arr.map((s) => String(s || '').trim()).filter(Boolean).forEach((s) => { const k = s.toLowerCase(); if (!seen.has(k)) { seen.add(k); out.push(s); } }); return out; } function dropExecSessions(execId) { for (const [tok, sess] of sessions.entries()) { if (sess.user && sess.user.username === 'exec:' + execId) sessions.delete(tok); } } app.get('/api/admin/executives', requireMasterAdmin, async (req, res) => { const state = await getState(); res.json({ executives: Object.values(state.executives || {}).map(publicExec) }); }); app.post('/api/admin/executives', requireMasterAdmin, async (req, res) => { try { const name = String(req.body?.name || '').trim(); const password = String(req.body?.password || ''); if (!name) return res.status(400).json({ error: 'Name required' }); if (password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' }); const state = await getState(); state.executives = state.executives || {}; if (findExecutive(state, name)) return res.status(409).json({ error: 'An executive with that name already exists' }); let uid = (slugify(name) || 'exec').slice(0, 40); let n = 2; while (state.executives[uid]) uid = `${slugify(name)}-${n++}`; state.executives[uid] = { id: uid, name, passwordHash: await bcrypt.hash(password, 10), scopeLocations: normalizeScopeList(req.body?.scopeLocations), scopeTeams: normalizeScopeList(req.body?.scopeTeams), createdAt: new Date().toISOString() }; logActivity(state, req.user, 'exec.create', 'created executive "' + name + '"'); await saveState(state); res.json({ ok: true, executive: publicExec(state.executives[uid]) }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to create executive' }); } }); app.put('/api/admin/executives/:id', requireMasterAdmin, async (req, res) => { try { const state = await getState(); const exec = state.executives && state.executives[req.params.id]; if (!exec) return res.status(404).json({ error: 'Executive not found' }); let changed = false; // Rename (e.g. add a last name). Keep the SAME id, so scope + password — which // live on this object keyed by id — are preserved. Login matches execs by name, // and exec check-offs are keyed by name, so guard duplicates and remap checks. if (typeof req.body?.name === 'string' && req.body.name.trim() && req.body.name.trim() !== exec.name) { const newName = req.body.name.trim().slice(0, 80); const clash = Object.values(state.executives).find((e) => e.id !== exec.id && normalizeName(e.name) === normalizeName(newName)); if (clash) return res.status(409).json({ error: 'Another executive already uses that name' }); const oldKey = normalizeName(exec.name); const newKey = normalizeName(newName); if (oldKey !== newKey && state.checks && state.checks[oldKey]) { state.checks[newKey] = Object.assign({}, state.checks[newKey] || {}, state.checks[oldKey]); delete state.checks[oldKey]; } exec.name = newName; changed = true; } if (Array.isArray(req.body?.scopeLocations)) { exec.scopeLocations = normalizeScopeList(req.body.scopeLocations); changed = true; } if (Array.isArray(req.body?.scopeTeams)) { exec.scopeTeams = normalizeScopeList(req.body.scopeTeams); changed = true; } if (typeof req.body?.password === 'string' && req.body.password.length > 0) { if (req.body.password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' }); exec.passwordHash = await bcrypt.hash(req.body.password, 10); changed = true; } if (changed) dropExecSessions(exec.id); // force re-login so new scope/password applies if (changed) logActivity(state, req.user, 'exec.edit', 'edited executive "' + exec.name + '"' + ((typeof req.body?.password === 'string' && req.body.password.length > 0) ? ' (password reset)' : '')); await saveState(state); res.json({ ok: true, executive: publicExec(exec) }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to update executive' }); } }); app.delete('/api/admin/executives/:id', requireMasterAdmin, async (req, res) => { try { const state = await getState(); if (!state.executives || !state.executives[req.params.id]) return res.status(404).json({ error: 'Executive not found' }); const deletedExecName = state.executives[req.params.id].name || req.params.id; delete state.executives[req.params.id]; dropExecSessions(req.params.id); logActivity(state, req.user, 'exec.delete', 'deleted executive "' + deletedExecName + '"'); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to delete executive' }); } }); // ─── Equipment library (master-managed; powers tappable photo terms) ─── // An entry: { id, name, aliases:[], images:[{url,caption}], image:'', note:'' }. // `images` is the multi-photo list (e.g. the device + its desktop config); `image` // is kept mirrored to images[0].url for backward compatibility. const EQUIP_MAX_PHOTOS = 12; // Normalize any incoming photos into a clean, de-duped [{url,caption}] list. // Accepts an array of {url,caption} or bare url strings; falls back to a legacy // single `image` string when no array is given. function normEquipImages(raw, fallbackImage) { const out = []; const seen = new Set(); const push = (u, c) => { const url = String(u || '').trim().slice(0, 2000); if (!url || seen.has(url)) return; seen.add(url); out.push({ url, caption: String(c || '').trim().slice(0, 120) }); }; if (Array.isArray(raw)) { for (const it of raw) { if (it && typeof it === 'object') push(it.url, it.caption); else push(it, ''); } } if (!out.length && fallbackImage) push(fallbackImage, ''); return out.slice(0, EQUIP_MAX_PHOTOS); } // Documents attached to an equipment entry: uploaded files (/doc-files/…) or // external links (Google Docs etc.). Same shape discipline as photos — trim, // dedupe, cap — plus a URL whitelist so nothing else can be stored as a doc. function normEquipDocs(raw) { const out = []; const seen = new Set(); if (Array.isArray(raw)) { for (const it of raw) { if (!it || typeof it !== 'object') continue; const url = String(it.url || '').trim().slice(0, 2000); if (!url || seen.has(url)) continue; if (!/^https?:\/\//i.test(url) && !/^\/doc-files\/[A-Za-z0-9._-]+$/.test(url)) continue; seen.add(url); out.push({ url, name: String(it.name || '').trim().slice(0, 80) || 'Document' }); } } return out.slice(0, 10); } // Every photo URL an equipment entry references (multi-photo aware, legacy-safe). function equipImageUrls(it) { if (it && Array.isArray(it.images) && it.images.length) return it.images.map((p) => p && p.url).filter(Boolean); return it && it.image ? [it.image] : []; } function sanitizeEquipment(body) { const name = String((body && body.name) || '').trim().slice(0, 80); if (!name) return null; let aliases = Array.isArray(body.aliases) ? body.aliases : (typeof body.aliases === 'string' ? body.aliases.split(',') : []); aliases = aliases.map((a) => String(a).trim()).filter(Boolean).slice(0, 20); const images = normEquipImages(body && body.images, body && body.image); return { name, aliases, images, image: images.length ? images[0].url : '', // mirror for legacy readers (thumbnail, term span) docs: normEquipDocs(body && body.docs), // attached documents (setup guides, manuals, Google Docs) note: String((body && body.note) || '').trim().slice(0, 2000), // room for multi-line troubleshooting steps scopeLocations: normalizeScopeList(body && body.scopeLocations), scopeTeams: normalizeScopeList(body && body.scopeTeams) }; } // Equipment scope mirrors executive scope: empty on both axes = global (links // everywhere). Otherwise an empty axis means "any" on that axis. Returns a // match score so the most location/team-specific entry wins when several share // a name (lets the same word resolve to the on-site photo per campus). function equipScopeScore(item, loc, team) { const locs = (item.scopeLocations || []).map((s) => normalizeName(s)); const teams = (item.scopeTeams || []).map((s) => normalizeName(s)); if (locs.length === 0 && teams.length === 0) return 0; // global const lLoc = (loc || '').trim() ? normalizeName(loc) : ''; const lTeam = (team || '').trim() ? normalizeName(team) : ''; const locOK = locs.length === 0 || locs.includes(lLoc); const teamOK = teams.length === 0 || teams.includes(lTeam); if (!locOK || !teamOK) return -1; // out of scope — not applicable here return (locs.length ? 2 : 0) + (teams.length ? 1 : 0); } // Is `item`'s scope fully within what `user` is allowed to manage? Master: yes. // Exec: the set of lists the item would link on must stay inside the exec's own // access. We enforce that the SAME way execCanEditList does — by intersection, // not independent axes — so an exec can never widen reach through an empty axis: // • No scope at all → no access (mirrors execCanEditList; never accidental full access). // • An axis the exec is restricted on (scope non-empty) → the item MUST name at // least one value there, all within the exec's allowance. Leaving that axis // empty would mean "everywhere on that axis", escaping their confinement. // • An axis the exec is unrestricted on (scope empty = "any") → no constraint. function equipScopeWithinUser(user, item) { if (isMasterAdmin(user)) return true; if (user.role !== 'executive') return false; const allowLocs = (user.scopeLocations || []).map((s) => normalizeName(s)); const allowTeams = (user.scopeTeams || []).map((s) => normalizeName(s)); if (allowLocs.length === 0 && allowTeams.length === 0) return false; // no scope = no access const itemLocs = (item.scopeLocations || []).map((s) => normalizeName(s)); const itemTeams = (item.scopeTeams || []).map((s) => normalizeName(s)); const locsOK = allowLocs.length === 0 ? true : (itemLocs.length > 0 && itemLocs.every((l) => allowLocs.includes(l))); const teamsOK = allowTeams.length === 0 ? true : (itemTeams.length > 0 && itemTeams.every((t) => allowTeams.includes(t))); return locsOK && teamsOK; } // May this user EDIT/DELETE this equipment entry? Strict: the item must stay fully // within the user's own scope (so they can't change gear that also reaches campuses // or teams outside their access). function canManageEquipment(user, item) { if (isMasterAdmin(user)) return true; return equipScopeWithinUser(user, item); } // May this user SEE this equipment entry in the library? Looser than manage: if the // item's scope OVERLAPS any location/team the exec can access, it's relevant to one // of their lists, so they can view it (even if they can't edit it). Global gear // (no scope) is visible to every editor. Example: an exec over North Attleboro // Production sees gear tagged North Attleboro, or Production, or both. function canSeeEquipment(user, item) { if (isMasterAdmin(user)) return true; if (user.role !== 'executive') return false; const allowLocs = (user.scopeLocations || []).map((s) => normalizeName(s)); const allowTeams = (user.scopeTeams || []).map((s) => normalizeName(s)); if (allowLocs.length === 0 && allowTeams.length === 0) return false; // no scope = nothing const itemLocs = (item.scopeLocations || []).map((s) => normalizeName(s)); const itemTeams = (item.scopeTeams || []).map((s) => normalizeName(s)); // Per axis: the item is in-reach if it's unrestricted there, the exec is // unrestricted there, or the two sets share a value. Both axes must hold. const locOK = itemLocs.length === 0 || allowLocs.length === 0 || itemLocs.some((l) => allowLocs.includes(l)); const teamOK = itemTeams.length === 0 || allowTeams.length === 0 || itemTeams.some((t) => allowTeams.includes(t)); return locOK && teamOK; } // May this user see this equipment AT ALL? This gates the /api/state projection, // which powers term-linking, GLOBAL SEARCH, and the lightbox — so a team login // can never even surface another campus's gear. Master: everything. Executives: // the overlap rule above. Team logins: exactly the gear that applies to their own // location+team (equipScopeScore ≥ 0 — global gear, or gear naming their scope), // the same rule that decides which gear their lists' words link to. function equipmentVisibleTo(user, item) { if (isMasterAdmin(user)) return true; if (user.role === 'executive') return canSeeEquipment(user, item); return equipScopeScore(item, user.location, user.team) >= 0; } // If `image` points at one of our uploaded files (/equip-images/), // return the on-disk path so we can delete it; otherwise null (external URL). function localUploadPath(image) { const m = /^\/equip-images\/([A-Za-z0-9._-]+)$/.exec(String(image || '')); if (!m) return null; const name = m[1]; if (name.includes('..') || name.includes('/')) return null; return path.join(UPLOAD_DIR, name); } async function removeUploadIfLocal(image) { const p = localUploadPath(image); if (!p) return; try { await fs.unlink(p); } catch (_) { /* already gone — fine */ } } // True if any OTHER equipment entry still points at this image — so we never // delete a file that a second entry (e.g. created via import) still uses. function imageStillReferenced(state, image, exceptId) { if (!image) return false; const items = (state.equipment && state.equipment.items) || {}; return Object.values(items).some((it) => it.id !== exceptId && equipImageUrls(it).includes(image)); } // Delete uploaded photos no equipment entry references anymore (e.g. an editor // uploaded a photo then abandoned the form). The grace window skips very recent // files so we never race an upload that's mid-edit and not yet saved. Keeps the // uploads dir from growing without bound. Runs on a daily cron. async function sweepOrphanUploads(graceMs) { try { const state = await getState(); const referenced = new Set(); // Scan the WHOLE state JSON for /equip-images/ references (mirrors the docs // sweep) — equipment photos AND request-item photos, plus any future field. // Building the set from equipImageUrls alone would sweep request photos. const blob = JSON.stringify(state); const re = /\/equip-images\/([A-Za-z0-9._-]+)/g; let m; while ((m = re.exec(blob))) referenced.add(m[1]); const files = await fs.readdir(UPLOAD_DIR); const cutoff = Date.now() - (graceMs || 3600000); let removed = 0; for (const name of files) { if (referenced.has(name)) continue; const fp = path.join(UPLOAD_DIR, name); let st; try { st = await fs.stat(fp); } catch (_) { continue; } if (!st.isFile() || st.mtimeMs > cutoff) continue; try { await fs.unlink(fp); removed++; } catch (_) { /* ignore */ } } if (removed) console.log(`[uploads] swept ${removed} orphaned photo(s)`); } catch (err) { console.error('[uploads] sweep failed:', err.message); } } // Snapshot checklists.json into BACKUP_DIR, one file per day, then prune to the // newest BACKUP_KEEP days. Cheap insurance against corruption or a bad edit — // the data file is the whole app. Runs nightly + once at startup. async function backupState(keepDays) { try { if (!fsSync.existsSync(STATE_FILE)) return; // nothing to back up yet const stamp = new Date().toISOString().slice(0, 10); // YYYY-MM-DD (server TZ-agnostic, fine for daily) const dest = path.join(BACKUP_DIR, `checklists-${stamp}.json`); await fs.copyFile(STATE_FILE, dest); const keep = Math.max(1, keepDays || BACKUP_KEEP); const files = (await fs.readdir(BACKUP_DIR)) .filter((f) => /^checklists-\d{4}-\d{2}-\d{2}\.json$/.test(f)) .sort(); // lexical sort == chronological for ISO dates if (files.length > keep) { for (const f of files.slice(0, files.length - keep)) { try { await fs.unlink(path.join(BACKUP_DIR, f)); } catch (_) { /* ignore */ } } } console.log(`[backup] saved ${path.basename(dest)} (keeping ${Math.min(files.length, keep)})`); } catch (err) { console.error('[backup] failed:', err.message); } } // Mirror uploaded media — equipment photos/videos (uploads/) and attached // documents (docs/) — into BACKUP_DIR/media, so pictures and documents are part // of the backup alongside the state snapshots. Filenames are content-unique and // immutable, so the mirror is INCREMENTAL: each file is copied once (re-copied // only on a size mismatch, i.e. a torn copy from a crash). A file DELETED from // the live dirs (photo replaced, orphan sweep, task removed) stays in the // mirror for BACKUP_KEEP days after it was last seen live — the same window as // the checklists-YYYY-MM-DD.json snapshots, so any restorable state backup can // still find every media file it references. Last-seen times are tracked in // media/manifest.json. To restore a file: copy it from backups/media// // back into data// (uploads or docs) — the URL keys off the filename. async function backupMedia(keepDays) { try { const mediaRoot = path.join(BACKUP_DIR, 'media'); const sets = [ { key: 'uploads', src: UPLOAD_DIR, dst: path.join(mediaRoot, 'uploads') }, { key: 'docs', src: DOCS_DIR, dst: path.join(mediaRoot, 'docs') } ]; const manifestPath = path.join(mediaRoot, 'manifest.json'); let manifest = {}; try { manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')) || {}; } catch (_) { manifest = {}; } if (typeof manifest !== 'object' || Array.isArray(manifest)) manifest = {}; const now = Date.now(); const cutoff = now - Math.max(1, keepDays || BACKUP_KEEP) * 86400000; let copied = 0, pruned = 0; for (const s of sets) { await fs.mkdir(s.dst, { recursive: true }); let live = []; try { live = await fs.readdir(s.src); } catch (_) { live = []; } const liveSet = new Set(); for (const name of live) { let st; try { st = await fs.stat(path.join(s.src, name)); } catch (_) { continue; } if (!st.isFile()) continue; liveSet.add(name); manifest[s.key + '/' + name] = now; // seen live right now const dstPath = path.join(s.dst, name); let dstSt = null; try { dstSt = await fs.stat(dstPath); } catch (_) { /* not mirrored yet */ } if (!dstSt || dstSt.size !== st.size) { try { await fs.copyFile(path.join(s.src, name), dstPath); copied++; } catch (err) { console.error('[backup] media copy failed:', name, err.message); } } } // Prune mirror copies whose live file has been gone longer than the window. let mirrored = []; try { mirrored = await fs.readdir(s.dst); } catch (_) { mirrored = []; } for (const name of mirrored) { if (liveSet.has(name)) continue; const k = s.key + '/' + name; if (manifest[k] == null) { manifest[k] = now; continue; } // pre-manifest copy: start its clock now if (manifest[k] < cutoff) { try { await fs.unlink(path.join(s.dst, name)); delete manifest[k]; pruned++; } catch (_) { /* ignore */ } } } // Drop manifest rows whose mirror copy no longer exists (pruned above, or hand-removed). Object.keys(manifest).forEach((k) => { if (k.indexOf(s.key + '/') !== 0) return; if (!fsSync.existsSync(path.join(s.dst, k.slice(s.key.length + 1)))) delete manifest[k]; }); } const tmp = manifestPath + '.tmp'; await fs.writeFile(tmp, JSON.stringify(manifest), 'utf-8'); await fs.rename(tmp, manifestPath); if (copied || pruned) console.log(`[backup] media: +${copied} copied, ${pruned} pruned`); } catch (err) { console.error('[backup] media failed:', err.message); } } // Decode a base64 data URL into a saved file under UPLOAD_DIR. The library UI uploads the // ORIGINAL full-resolution bytes for web-displayable images within budget (or transcodes an // oversized/unknown source down to a bounded 4096px JPEG), so files can be several MB. const IMAGE_EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif' }; // Short reference clips only. MOV (quicktime) plays in Safari; MP4/WEBM everywhere. // Files land in uploads/ (excluded from the deploy tarball, and mirrored — not // snapshotted daily — by backupMedia), so a big clip never bloats state or the // daily state snapshots. const VIDEO_EXT = { 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/quicktime': 'mov', 'video/ogg': 'ogv' }; const MAX_IMAGE_BYTES = 20 * 1024 * 1024; const MAX_VIDEO_BYTES = 30 * 1024 * 1024; app.post('/api/equipment/upload-image', requireTeamAdmin, async (req, res) => { try { const dataUrl = String((req.body && req.body.dataUrl) || ''); const m = /^data:([\w/+.-]+);base64,([\s\S]+)$/.exec(dataUrl); if (!m) return res.status(400).json({ error: 'Expected a base64 media data URL' }); const mime = m[1].toLowerCase(); const isVideo = !!VIDEO_EXT[mime]; const ext = IMAGE_EXT[mime] || VIDEO_EXT[mime]; if (!ext) return res.status(400).json({ error: 'Unsupported file — images: JPG, PNG, WEBP, GIF; video: MP4, WEBM, MOV (HEIC will not display).' }); const buf = Buffer.from(m[2], 'base64'); if (!buf.length) return res.status(400).json({ error: 'Empty file' }); const cap = isVideo ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES; if (buf.length > cap) return res.status(413).json({ error: isVideo ? 'Video too large (max 30 MB). Trim it or lower the resolution.' : 'Image too large (max 20 MB).' }); const name = `${Date.now().toString(36)}-${crypto.randomBytes(6).toString('hex')}.${ext}`; await fs.writeFile(path.join(UPLOAD_DIR, name), buf); res.json({ ok: true, path: `/equip-images/${name}`, kind: isVideo ? 'video' : 'image' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Upload failed' }); } }); // ── Document attachments ───────────────────────────────────────────── // Upload a document (setup guide, rundown, spec) to link from a task/note // description — the doc counterpart of the equipment photo upload. Editors // only (they author descriptions). Type is judged by the ORIGINAL filename's // extension: data-URL mimes for office files are unreliable (browsers often // send application/octet-stream), and the extension is what the OS/browser // uses to open the download anyway. const DOC_EXTS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'rtf']; const MAX_DOC_BYTES = 25 * 1024 * 1024; app.post('/api/documents/upload', requireTeamAdmin, async (req, res) => { try { const dataUrl = String((req.body && req.body.dataUrl) || ''); const original = String((req.body && req.body.name) || '').trim(); const m = /^data:([\w/+.-]+);base64,([\s\S]+)$/.exec(dataUrl); if (!m) return res.status(400).json({ error: 'Expected a base64 file data URL' }); const extM = /\.([A-Za-z0-9]{1,8})$/.exec(original); const ext = extM ? extM[1].toLowerCase() : ''; if (!DOC_EXTS.includes(ext)) { return res.status(400).json({ error: 'Unsupported file type — allowed: ' + DOC_EXTS.join(', ').toUpperCase() }); } const buf = Buffer.from(m[2], 'base64'); if (!buf.length) return res.status(400).json({ error: 'Empty file' }); if (buf.length > MAX_DOC_BYTES) return res.status(413).json({ error: 'File too large (max 25 MB).' }); // Keep a sanitized version of the ORIGINAL name in the stored filename so a // downloaded copy is recognizable; the random prefix keeps names unique // (which is what makes the immutable caching + sweep-by-name safe). const base = original.slice(0, original.length - ext.length - 1) .replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 60) || 'document'; const name = `${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}-${base}.${ext}`; await fs.writeFile(path.join(DOCS_DIR, name), buf); const state = await getState(); logActivity(state, req.user, 'document.upload', 'uploaded document "' + (original || name) + '"'); await saveState(state); res.json({ ok: true, path: `/doc-files/${name}`, name: original || name }); } catch (err) { console.error(err); res.status(500).json({ error: 'Upload failed' }); } }); // Delete uploaded documents nothing references anymore (an upload whose link was // never saved, or whose task/note was deleted). References are found by scanning // the WHOLE state JSON for /doc-files/ — descriptions, notes, feedback, // wherever a link may live now or in a future field. Same grace window as the // photo sweep so we never race an upload that's mid-edit and not yet saved. async function sweepOrphanDocs(graceMs) { try { const state = await getState(); const referenced = new Set(); const blob = JSON.stringify(state); const re = /\/doc-files\/([A-Za-z0-9._-]+)/g; let m; while ((m = re.exec(blob))) referenced.add(m[1]); const files = await fs.readdir(DOCS_DIR); const cutoff = Date.now() - (graceMs || 24 * 3600000); // docs get a longer grace than photos let removed = 0; for (const name of files) { if (referenced.has(name)) continue; const fp = path.join(DOCS_DIR, name); let st; try { st = await fs.stat(fp); } catch (_) { continue; } if (!st.isFile() || st.mtimeMs > cutoff) continue; try { await fs.unlink(fp); removed++; } catch (_) { /* ignore */ } } if (removed) console.log(`[docs] swept ${removed} orphaned document(s)`); } catch (err) { console.error('[docs] sweep failed:', err.message); } } app.get('/api/admin/equipment', requireTeamAdmin, async (req, res) => { const state = await getState(); const all = (state.equipment && state.equipment.items) || {}; // Execs SEE any gear whose scope overlaps their access, but can only EDIT the // gear fully within their scope. _canManage tells the client which to make editable. // (Copy each item so the stored object is never mutated with the transient flag.) const items = {}; Object.keys(all).forEach((id) => { if (canSeeEquipment(req.user, all[id])) { items[id] = Object.assign({}, all[id], { _canManage: canManageEquipment(req.user, all[id]) }); } }); res.json({ items }); }); app.post('/api/admin/equipment', requireTeamAdmin, async (req, res) => { try { const clean = sanitizeEquipment(req.body || {}); if (!clean) return res.status(400).json({ error: 'Name is required' }); if (!equipScopeWithinUser(req.user, clean)) { return res.status(403).json({ error: 'Pick at least one of your own locations/teams for this equipment.' }); } const state = await getState(); state.equipment = state.equipment || { items: {} }; state.equipment.items = state.equipment.items || {}; let id = (slugify(clean.name) || 'item').slice(0, 40); let n = 2; while (state.equipment.items[id]) id = `${slugify(clean.name)}-${n++}`; state.equipment.items[id] = Object.assign({ id }, clean, { createdAt: new Date().toISOString() }); logActivity(state, req.user, 'equip.create', 'added equipment "' + clean.name + '"'); await saveState(state); res.json({ ok: true, id, item: state.equipment.items[id] }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to add equipment' }); } }); app.put('/api/admin/equipment/:id', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const existing = state.equipment && state.equipment.items && state.equipment.items[req.params.id]; if (!existing) return res.status(404).json({ error: 'Equipment not found' }); if (!canManageEquipment(req.user, existing)) return res.status(403).json({ error: 'Not your equipment' }); const clean = sanitizeEquipment(req.body || {}); if (!clean) return res.status(400).json({ error: 'Name is required' }); if (!equipScopeWithinUser(req.user, clean)) { return res.status(403).json({ error: 'Pick at least one of your own locations/teams for this equipment.' }); } // Photos removed from this entry? Delete each orphaned upload — unless another // entry still references it. (Multi-photo aware: diff old vs new URL sets.) const keptUrls = new Set(equipImageUrls(clean)); for (const url of equipImageUrls(existing)) { if (!keptUrls.has(url) && !imageStillReferenced(state, url, existing.id)) await removeUploadIfLocal(url); } state.equipment.items[req.params.id] = Object.assign({ id: existing.id }, clean, { createdAt: existing.createdAt }); logActivity(state, req.user, 'equip.edit', 'edited equipment "' + clean.name + '"'); await saveState(state); res.json({ ok: true, item: state.equipment.items[req.params.id] }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to update equipment' }); } }); app.delete('/api/admin/equipment/:id', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const existing = state.equipment && state.equipment.items && state.equipment.items[req.params.id]; if (!existing) return res.json({ ok: true }); if (!canManageEquipment(req.user, existing)) return res.status(403).json({ error: 'Not your equipment' }); const removedEquipName = existing.name || req.params.id; const removedUrls = equipImageUrls(existing); delete state.equipment.items[req.params.id]; for (const url of removedUrls) { if (!imageStillReferenced(state, url, existing.id)) await removeUploadIfLocal(url); } logActivity(state, req.user, 'equip.delete', 'deleted equipment "' + removedEquipName + '"'); await saveState(state); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to delete equipment' }); } }); // ─── Incident reports (gear problems) ──────────────────────────── // Any volunteer can report a piece of gear as broken. Severity gates urgency: // 'down' → the gear is unusable RIGHT NOW; email fires instantly to the // team recipients whose scope covers the incident (+ REPORT_EMAIL). // 'minor' → a non-blocking nuisance; batched — it rides along in the digest // report's "Open issues" section instead of an instant email. // Reports are scoped: a member/team-admin files under their own login scope and // only sees issues their scope covers; execs/master span their scope. See // incidentScopeForReporter / incidentVisibleTo / incidentRecipientsFor. const INCIDENT_SEVERITIES = ['down', 'minor']; const MAX_INCIDENTS = 2000; function sanitizeSeverity(s) { return INCIDENT_SEVERITIES.includes(s) ? s : 'minor'; } function newIncidentId() { return 'inc_' + Date.now().toString(36) + '-' + crypto.randomBytes(4).toString('hex'); } // The (location, team) an incident belongs to. A member/team-admin can ONLY file // under their own login scope (no spoofing another campus/team). Master and execs // may target a scope they can reach (e.g. reporting from a gear detail while // overseeing a campus), taking it from the request body and clamping execs. function incidentScopeForReporter(user, body) { const bodyLoc = String((body && body.location) || '').trim(); const bodyTeam = String((body && body.team) || '').trim(); if (isMasterAdmin(user)) return { location: bodyLoc, team: bodyTeam }; if (user.role === 'executive') { const loc = bodyLoc || (Array.isArray(user.scopeLocations) && user.scopeLocations[0]) || ''; const team = bodyTeam || (Array.isArray(user.scopeTeams) && user.scopeTeams[0]) || ''; return { location: loc, team }; } return { location: user.location || '', team: user.team || '' }; } // May this user SEE an incident? Master = all; exec = anything in their edit // scope; member/team-admin = their own login scope (their team + building-wide + // global issues at their campus). Never another team's issues → no cross leak. function incidentVisibleTo(user, inc) { if (isMasterAdmin(user)) return true; const iLoc = (inc.location || '').trim(); const iTeam = (inc.team || '').trim(); if (user.role === 'executive') return execCanEditList(user, { location: iLoc, team: iTeam }); const uLoc = normalizeName(user.location || ''); const uTeam = (user.team || '').trim() ? normalizeName(user.team) : ''; const nLoc = iLoc ? normalizeName(iLoc) : ''; const nTeam = iTeam ? normalizeName(iTeam) : ''; if (!nLoc) return true; // location-less (global) issue — everyone if (nLoc !== uLoc) return false; // different campus if (!nTeam) return true; // building-wide — any team in the building return !uTeam || nTeam === uTeam; // team-specific — must be that team } // May this user RESOLVE/reopen an incident? Editors only, and only within scope. function canManageIncident(user, inc) { if (isMasterAdmin(user)) return true; if (user.role === 'executive') return execCanEditList(user, { location: inc.location || '', team: inc.team || '' }); // Team admin: their own team's issues. if (user.role === 'admin') { const uLoc = normalizeName(user.location || ''); const uTeam = (user.team || '').trim() ? normalizeName(user.team) : ''; const nLoc = (inc.location || '').trim() ? normalizeName(inc.location) : ''; const nTeam = (inc.team || '').trim() ? normalizeName(inc.team) : ''; return !!uTeam && nLoc === uLoc && nTeam === uTeam; } return false; } // Team accounts (with recipients) whose scope COVERS the incident. Reuses // listAppliesToScope by treating the account's (location, team) as a "list": // a global account gets everything, a building account gets its campus, a team // account gets only its own team's issues. function incidentRecipientsFor(state, inc) { const set = new Set(); for (const acct of teamsWithRecipients(state)) { if (listAppliesToScope({ location: acct.location, team: acct.team }, inc.location, inc.team)) { (acct.emailRecipients || []).forEach((r) => set.add(r)); } } return Array.from(set); } function incidentScopeLabel(inc) { return [inc.location, inc.team].filter((s) => s && String(s).trim()).join(' · ') || 'Unassigned'; } // Fire the instant "gear is down" email. No transporter / no recipients → a // harmless no-op (the incident is still recorded and shows in the next digest). async function sendIncidentEmail(state, inc) { const transporter = buildTransporter(); if (!transporter) { console.warn('[incident] SMTP not configured — instant email skipped'); return { emailed: false, to: [] }; } const to = incidentRecipientsFor(state, inc); if (REPORT_EMAIL && !to.includes(REPORT_EMAIL)) to.push(REPORT_EMAIL); // owner oversight on urgent issues if (!to.length) { console.warn('[incident] no recipients for', incidentScopeLabel(inc)); return { emailed: false, to: [] }; } const scope = incidentScopeLabel(inc); const gear = inc.equipName || 'Equipment'; const when = formatTime(inc.ts); const subject = `[GEAR DOWN] ${gear} — ${scope}`; const lines = [ `⚠ GEAR DOWN — reported ${when}`, ``, `Gear: ${gear}`, `Where: ${scope}`, `Reported: ${inc.by}`, inc.what ? `Problem: ${inc.what}` : '', inc.details ? `\nDetails:\n${inc.details}` : '', ``, `— This is an automatic alert from the checklist app. The gear was flagged as unusable right now.` ].filter((l) => l !== ''); const html = `
⚠ Gear Down — needs attention now
${inc.what ? `` : ''}
Gear${escapeHtml(gear)}
Where${escapeHtml(scope)}
Reported by${escapeHtml(inc.by)} · ${escapeHtml(when)}
Problem${escapeHtml(inc.what)}
${inc.details ? `
${escapeHtml(inc.details)}
` : ''}

Automatic alert from the checklist app — this gear was flagged as unusable right now.

`; await transporter.sendMail({ from: SMTP_FROM, to: to.join(', '), subject, text: lines.join('\n'), html }); console.log(`[incident] DOWN "${gear}" (${scope}) emailed to ${to.join(', ')}`); return { emailed: true, to }; } // Report a gear problem. Open to ANY signed-in user (volunteers included). app.post('/api/incidents', requireUser, async (req, res) => { try { const body = req.body || {}; const severity = sanitizeSeverity(body.severity); const what = String(body.what || '').trim().slice(0, 120); const details = String(body.details || '').trim().slice(0, 2000); const equipId = body.equipId ? String(body.equipId).slice(0, 80) : ''; let equipName = String(body.equipName || '').trim().slice(0, 120); if (!what && !equipName) return res.status(400).json({ error: 'Describe the problem or pick the gear.' }); const state = await getState(); if (equipId && state.equipment && state.equipment.items && state.equipment.items[equipId]) { equipName = state.equipment.items[equipId].name || equipName; // canonical name from the library } const { location, team } = incidentScopeForReporter(req.user, body); let by = req.user.displayName || req.user.username; if (req.user.role === 'user') { const n = String(body.by || '').trim().slice(0, 40); if (n) by = n; } const inc = { id: newIncidentId(), ts: new Date().toISOString(), by, reporterRole: activityRole(req.user), location, team, equipId, equipName, listId: body.listId ? String(body.listId).slice(0, 80) : '', severity, what, details, status: 'open', resolvedBy: '', resolvedAt: '', emailedAt: '' }; if (!Array.isArray(state.incidents)) state.incidents = []; state.incidents.push(inc); if (state.incidents.length > MAX_INCIDENTS) state.incidents = state.incidents.slice(-MAX_INCIDENTS); logActivity(state, req.user, 'incident.report', `reported ${severity === 'down' ? 'GEAR DOWN' : 'an issue'}: "${equipName || what}" (${incidentScopeLabel(inc)})`, by); // Instant email for 'down' only. Never let a mail failure lose the report — // it's already saved below and will still surface in the digest. let emailed = false; if (severity === 'down') { try { const r = await sendIncidentEmail(state, inc); emailed = r.emailed; if (emailed) inc.emailedAt = new Date().toISOString(); } catch (e) { console.error('[incident] instant email failed:', e && e.message); } } await saveState(state); res.json({ ok: true, incident: inc, emailed }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to report the issue' }); } }); // Scoped list of incidents (mirrors what /api/state embeds, but on demand and // filterable). ?status=open|resolved · ?equipId=… newest-first. app.get('/api/incidents', requireUser, async (req, res) => { try { const state = await getState(); const status = String(req.query.status || '').trim(); const equipId = String(req.query.equipId || '').trim(); const out = (Array.isArray(state.incidents) ? state.incidents : []) .filter((inc) => incidentVisibleTo(req.user, inc)) .filter((inc) => !status || inc.status === status) .filter((inc) => !equipId || inc.equipId === equipId) .slice(-500) .reverse(); res.json({ incidents: out }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to load incidents' }); } }); // Resolve / reopen an incident (editors only, in scope). app.post('/api/incidents/:id/resolve', requireTeamAdmin, async (req, res) => { try { const state = await getState(); const inc = (state.incidents || []).find((x) => x.id === req.params.id); if (!inc) return res.status(404).json({ error: 'Incident not found' }); if (!canManageIncident(req.user, inc)) return res.status(403).json({ error: 'Not in your area' }); const reopen = !!(req.body && req.body.reopen); if (reopen) { inc.status = 'open'; inc.resolvedBy = ''; inc.resolvedAt = ''; } else { inc.status = 'resolved'; inc.resolvedBy = req.user.displayName || req.user.username; inc.resolvedAt = new Date().toISOString(); } logActivity(state, req.user, reopen ? 'incident.reopen' : 'incident.resolve', `${reopen ? 'reopened' : 'resolved'} issue: "${inc.equipName || inc.what}" (${incidentScopeLabel(inc)})`); await saveState(state); res.json({ ok: true, incident: inc }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to update the incident' }); } }); // ─── Email digest ──────────────────────────────────────────────── function buildTransporter() { if (!SMTP_HOST || !SMTP_USER || !SMTP_PASS) return null; return nodemailer.createTransport({ host: SMTP_HOST, port: SMTP_PORT, secure: SMTP_SECURE, auth: { user: SMTP_USER, pass: SMTP_PASS } }); } function formatTime(iso) { if (!iso) return ''; const d = new Date(iso); return d.toLocaleString('en-US', { timeZone: REPORT_TZ, weekday: 'short', hour: 'numeric', minute: '2-digit' }); } // A "scope key" is either a plain location ("Woonsocket") or a location+team // composite ("Woonsocket::Production"). splitScope pulls the two parts back out. // Plain (teamless) keys are how every check was stored before teams existed, so // this is fully backward compatible. function splitScope(key) { const raw = String(key || ''); const idx = raw.indexOf('::'); if (idx === -1) return { location: raw, team: '' }; return { location: raw.slice(0, idx), team: raw.slice(idx + 2) }; } function collectLocations(state) { const set = new Set(); state.lists.forEach((list) => { const loc = (list.location || '').trim(); if (loc) set.add(normalizeName(loc)); }); Object.keys(state.checks || {}).forEach((k) => { // Collapse any team scope key down to its location so team checks never // surface as a phantom "WOONSOCKET::PRODUCTION" section in the global digest. const loc = splitScope(k).location; if (loc && loc !== 'Anonymous') set.add(loc); }); return Array.from(set).sort((a, b) => a.localeCompare(b)); } // Render one titled section (a location, or later a team) from its lists, // reading each item's status via getChecksForList(list) -> { [itemId]: check }. // Output strings are kept identical to the original report so the weekly email // is unchanged for existing data. function buildReportSection(title, lists, getChecksForList) { const lines = []; const html = []; let done = 0; let missed = 0; if (!Array.isArray(lists) || lists.length === 0) { return { lines, html, done, missed }; } lines.push(''); lines.push(`######## ${String(title).toUpperCase()} ########`); lines.push(''); html.push(`

${escapeHtml(title)}

`); lists.forEach((list) => { // Notes are reference-only (no completion), so they never appear in the report. Count/iterate // TASK items only, and skip the whole list block when it has zero tasks (a notes-only list would // otherwise print an empty header + "Completed 0/0 · Missed 0"). const taskItems = (list.items || []).filter((i) => i.kind !== 'note'); if (taskItems.length === 0) return; const checksForList = getChecksForList(list) || {}; lines.push(`============================================`); lines.push(list.name.toUpperCase()); lines.push(`============================================`); html.push(`

${escapeHtml(list.name)}

`); html.push(``); let listDone = 0; let listMissed = 0; taskItems.forEach((item) => { const c = checksForList[item.id]; const isDone = !!(c && c.completed); const symbol = isDone ? '✓' : '✗'; const who = isDone ? `${c.completedBy || title} (${formatTime(c.completedDate)})` : 'not done'; lines.push(`${symbol} ${item.title.padEnd(40, ' ')} — ${who}`); const bg = isDone ? '#eaf7ea' : '#fdecea'; const color = isDone ? '#1a7f37' : '#a8071a'; html.push(` `); if (isDone) listDone += 1; else listMissed += 1; }); html.push(`
${symbol} ${escapeHtml(item.title)}${item.description ? `
${escapeHtml(item.description)}
` : ''}
${escapeHtml(who)}
`); lines.push(''); lines.push(`Completed: ${listDone}/${listDone + listMissed} Missed: ${listMissed}`); lines.push(''); html.push(`

Completed ${listDone}/${listDone + listMissed} · Missed ${listMissed}

`); done += listDone; missed += listMissed; }); return { lines, html, done, missed }; } // Stitch sections into the final { text, html, totals } email body. function assembleReport(sections) { const lines = []; const html = []; let totalDone = 0; let totalMissed = 0; sections.forEach((section) => { lines.push(...section.lines); html.push(...section.html); totalDone += section.done; totalMissed += section.missed; }); return { text: lines.join('\n'), html: `
${html.join('')}
`, totalDone, totalMissed }; } // Append an "Open gear issues" section to an assembled report. `filterFn(inc)` // picks which open incidents belong in this report (global = all; per-team = the // account's scope). Down issues are flagged 🔴, minor 🟡. Batched ('minor') // reports reach recipients HERE — this is their delivery path. No open issues in // scope → the report is returned untouched (weekly email stays identical for the // no-incident case). function appendIncidentsSection(report, state, filterFn) { const open = (Array.isArray(state.incidents) ? state.incidents : []) .filter((inc) => inc.status === 'open' && filterFn(inc)) .sort((a, b) => (a.severity === b.severity ? String(b.ts).localeCompare(a.ts) : (a.severity === 'down' ? -1 : 1))); if (!open.length) return report; const down = open.filter((i) => i.severity === 'down').length; const lines = ['', `######## OPEN GEAR ISSUES (${open.length}${down ? `, ${down} DOWN` : ''}) ########`, '']; const html = [`

Open gear issues (${open.length}${down ? ` · ${down} down` : ''})

`, ``]; open.forEach((inc) => { const sev = inc.severity === 'down' ? '🔴 DOWN' : '🟡 minor'; const scope = incidentScopeLabel(inc); const gear = inc.equipName || inc.what || 'Equipment'; const desc = [inc.what && inc.equipName ? inc.what : '', inc.details].filter(Boolean).join(' — '); lines.push(`${inc.severity === 'down' ? '🔴' : '🟡'} ${gear} [${scope}] — ${desc || '(no details)'} (${inc.by}, ${formatTime(inc.ts)})`); const bg = inc.severity === 'down' ? '#fdecea' : '#fff8e6'; html.push(``); }); html.push('
${sev} ${escapeHtml(gear)} ${escapeHtml(scope)}${desc ? `
${escapeHtml(desc)}
` : ''}
${escapeHtml(inc.by)}
${escapeHtml(formatTime(inc.ts))}
'); return { text: (report.text || '') + '\n' + lines.join('\n'), html: (report.html || '').replace(/<\/div>\s*$/, '') + html.join('') + '', totalDone: report.totalDone, totalMissed: report.totalMissed }; } // Readiness "report card" wrapper (Phase 6): prepend an at-a-glance readiness // header (task completion % + open-issue/down tallies + new-this-week) and append // a NOTES recap (reference note items, which the task sections deliberately skip). // `incidentFilterFn` scopes the incident tallies to this report; `lists` is the // same set the report's task sections were built from (for the notes recap). function finalizeReport(report, state, { lists, incidentFilterFn }) { const done = report.totalDone || 0; const missed = report.totalMissed || 0; const total = done + missed; const pct = total ? Math.round((done / total) * 100) : 0; const inScope = (Array.isArray(state.incidents) ? state.incidents : []).filter((i) => incidentFilterFn(i)); const open = inScope.filter((i) => i.status === 'open'); const down = open.filter((i) => i.severity === 'down').length; const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString(); const newThisWeek = inScope.filter((i) => String(i.ts) >= weekAgo).length; const emoji = (down > 0 || (total && pct < 60)) ? '🔴' : (!total || pct < 90 || open.length) ? '🟡' : '🟢'; const issueText = open.length ? `${open.length} open gear issue${open.length === 1 ? '' : 's'}${down ? ` (${down} DOWN)` : ''}` : 'no open gear issues'; const headText = `${emoji} READINESS: ${total ? `${done}/${total} tasks done (${pct}%)` : 'no tasks tracked'} · ${issueText}${newThisWeek ? ` · ${newThisWeek} new this week` : ''}\n\n`; const pctColor = !total ? '#888' : pct >= 90 ? '#1a7f37' : pct >= 60 ? '#8a6d00' : '#a8071a'; const headHtml = `
${emoji} Readiness
${total ? `${done}/${total} tasks (${pct}%)` : 'No tasks tracked'}  ·  ${open.length ? `${open.length} open gear issue${open.length === 1 ? '' : 's'}${down ? ` (${down} down)` : ''}` : 'No open gear issues'} ${newThisWeek ? ` ·  ${newThisWeek} new this week` : ''}
`; // Notes recap (reference note items). const noteText = []; const noteHtml = []; (lists || []).forEach((list) => { const notes = (list.items || []).filter((i) => i.kind === 'note' && (i.title || i.description)); if (!notes.length) return; noteText.push(`--- ${list.name} ---`); notes.forEach((n) => noteText.push(`• ${n.title}${n.description ? ': ' + String(n.description).replace(/\s+/g, ' ').slice(0, 200) : ''}`)); noteText.push(''); noteHtml.push(`

${escapeHtml(list.name)}

    ` + notes.map((n) => `
  • ${escapeHtml(n.title)}${n.description ? `: ${escapeHtml(String(n.description).replace(/\s+/g, ' ').slice(0, 200))}` : ''}
  • `).join('') + `
`); }); let notesText = ''; let notesHtml = ''; if (noteText.length) { notesText = '\n\n######## NOTES ########\n\n' + noteText.join('\n'); notesHtml = `

Notes

` + noteHtml.join(''); } const text = headText + (report.text || '') + notesText; let html = report.html || ''; html = html.replace(/^(]*>)/, '$1' + headHtml); // prepend readiness header inside the wrapper html = html.replace(/<\/div>\s*$/, notesHtml + ''); // append notes before the wrapper close return { text, html, totalDone: done, totalMissed: missed }; } // GLOBAL digest — one section per location, reading that location's teamless // checks. Behavior is unchanged from the original single-function report. function buildReport(state) { const locations = collectLocations(state); if (locations.length === 0) locations.push('(No one checked in)'); const sections = locations.map((locKey) => { const lists = state.lists.filter((list) => { const listLoc = (list.location || '').trim(); return !listLoc || normalizeName(listLoc) === locKey; }); return buildReportSection( locKey, lists, (list) => (state.checks && state.checks[locKey] && state.checks[locKey][list.id]) || {} ); }); const incF = () => true; return finalizeReport(appendIncidentsSection(assembleReport(sections), state, incF), state, { lists: state.lists, incidentFilterFn: incF }); } // PER-TEAM digest — the lists visible to one team account (team + building-wide // + global), read from that team's scope key. function teamLabel(account) { const scope = [account.location, account.team].filter(Boolean).join(' · '); return scope || account.displayName || account.username; } function buildTeamReport(state, account) { const key = scopeKey(account.location, account.team); const lists = state.lists.filter((l) => listAppliesToScope(l, account.location, account.team)); const section = buildReportSection( teamLabel(account), lists, (list) => (state.checks && state.checks[key] && state.checks[key][list.id]) || {} ); const incF = (inc) => listAppliesToScope({ location: account.location, team: account.team }, inc.location, inc.team); return finalizeReport(appendIncidentsSection(assembleReport([section]), state, incF), state, { lists, incidentFilterFn: incF }); } // Team accounts that have at least one email recipient configured. function teamsWithRecipients(state) { return Object.values(state.users || {}).filter((u) => Array.isArray(u.emailRecipients) && u.emailRecipients.length > 0); } // Of those, the ones a given user is allowed to send a report for: // master = all; executive = teams inside their scope; team-admin = their own (loc, team). function teamsInScopeWithRecipients(state, user) { const all = teamsWithRecipients(state); if (isMasterAdmin(user)) return all; if (user.role === 'executive') return all.filter((acct) => execCanEditList(user, { location: acct.location, team: acct.team })); const uLoc = normalizeName(user.location || ''); const uTeam = (user.team || '').trim() ? normalizeName(user.team) : ''; return all.filter((acct) => normalizeName(acct.location || '') === uLoc && ((acct.team || '').trim() ? normalizeName(acct.team) : '') === uTeam); } // Normalise check-in names so "woonsocket", "Woonsocket", "WOONSOCKET" // all collapse to a single canonical "Woonsocket" in the report. function normalizeName(raw) { if (typeof raw !== 'string') return 'Anonymous'; const cleaned = raw.trim().replace(/\s+/g, ' ').slice(0, 80); if (!cleaned) return 'Anonymous'; return cleaned .toLowerCase() .replace(/(^|[\s\-'])(\p{L})/gu, (_, sep, ch) => sep + ch.toUpperCase()); } // Build the per-(location, team) key used to store/look up checks. // A teamless caller collapses to the bare location key — exactly how checks // were keyed before teams existed — so existing check data keeps resolving. function scopeKey(location, team) { const loc = location && String(location).trim() ? normalizeName(location) : ''; const tm = team && String(team).trim() ? normalizeName(team) : ''; if (loc && tm) return loc + '::' + tm; return loc; } // Accept recipients as an array or a comma/space/semicolon-separated string; // trim, drop empties, keep things that look like an email, cap the count. function normalizeRecipients(input) { let arr = []; if (Array.isArray(input)) arr = input; else if (typeof input === 'string') arr = input.split(/[\s,;]+/); const seen = new Set(); const out = []; arr.map((s) => String(s).trim()).filter(Boolean).forEach((addr) => { if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return; const key = addr.toLowerCase(); if (seen.has(key)) return; seen.add(key); out.push(addr); }); return out.slice(0, 20); } function slugify(s) { return String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); } // Canonical login id from a location + team, e.g. "South Coast" + "Production" // -> "south-coast.production". Must satisfy the username regex + 40-char cap. function makeUsername(location, team) { const locSlug = slugify(location); const teamSlug = slugify(team); const u = (teamSlug ? `${locSlug}.${teamSlug}` : locSlug).replace(/[^a-z0-9._-]/g, ''); return u.slice(0, 40); } // Which lists apply to a (location, team) scope: // global (no location, no team) | building-wide (location, no team) | team (location + team) function listAppliesToScope(list, location, team) { const listLoc = (list.location || '').trim() ? normalizeName(list.location) : ''; const listTeam = (list.team || '').trim() ? normalizeName(list.team) : ''; const loc = location && String(location).trim() ? normalizeName(location) : ''; const tm = team && String(team).trim() ? normalizeName(team) : ''; if (!listLoc && !listTeam) return true; // global — everyone if (listLoc !== loc) return false; // different (or missing) building if (!listTeam) return true; // building-wide — all teams in the building return listTeam === tm; // team-specific — must match team } // Who may EDIT a list. Master admin: any list. Executive: any list inside their // assigned scope. Team admin: only its own team-specific list (building-wide / // global lists are master-owned). function ownsList(user, list) { if (isMasterAdmin(user)) return true; if (user.role === 'executive') return execCanEditList(user, list); const uLoc = normalizeName(user.location || ''); const uTeam = (user.team || '').trim() ? normalizeName(user.team) : ''; const lLoc = (list.location || '').trim() ? normalizeName(list.location) : ''; const lTeam = (list.team || '').trim() ? normalizeName(list.team) : ''; return !!uTeam && lLoc === uLoc && lTeam === uTeam; } // An executive's access is the INTERSECTION of their location and team scope: // they edit a list whose location is allowed AND whose team is allowed. An empty // column means "any" on that axis (locations=[Woonsocket], teams=[] -> every // Woonsocket team; locations=[], teams=[Production] -> Production everywhere; // locations=[North Attleboro], teams=[Admin] -> ONLY North Attleboro's Admin lists). // No scope assigned at all = no access (never accidentally full access). function execCanEditList(user, list) { const locs = (user.scopeLocations || []).map((s) => normalizeName(s)); const teams = (user.scopeTeams || []).map((s) => normalizeName(s)); if (locs.length === 0 && teams.length === 0) return false; const lLoc = (list.location || '').trim() ? normalizeName(list.location) : ''; const lTeam = (list.team || '').trim() ? normalizeName(list.team) : ''; const locationOK = locs.length === 0 || locs.includes(lLoc); const teamOK = teams.length === 0 || teams.includes(lTeam); return locationOK && teamOK; } function makeListId(name, location, team, state) { const base = slugify([name, location, team].filter(Boolean).join('-')) || 'list'; let id = base; let n = 2; while (state.lists.find((l) => l.id === id)) id = `${base}-${n++}`; return id; } function clampInt(v, min, max, fallback) { const n = Number(v); return Number.isInteger(n) && n >= min && n <= max ? n : fallback; } // Allowed list colors. Either a named preset OR a custom #rrggbb hex (from the // color picker). '' = default/no color. const LIST_COLORS = ['blue', 'green', 'amber', 'red', 'purple', 'teal', 'pink', 'slate', 'orange', 'yellow', 'lime', 'cyan', 'indigo', 'brown']; function sanitizeColor(c) { const v = String(c || '').trim().toLowerCase(); if (LIST_COLORS.includes(v)) return v; if (/^#[0-9a-f]{6}$/.test(v)) return v; // custom hex return ''; } // The three checklist columns, in their default left→right (desktop) / top→bottom (mobile) order. const COLUMN_KEYS = ['todo', 'done', 'notes', 'feedback']; // A list's shared column order. Keep only valid keys, dedupe, then append any missing key so the // result is ALWAYS the full set in some order. Any non-array / garbage → the default order. // Custom task sections: the buckets a list's UNCHECKED tasks are grouped into // ("To Do" by default; owners can add more — e.g. Monday…Sunday for a // maintenance list — and rename any of them, the default included). The first // entry is ALWAYS the default (stable id 'todo', never removable); ids are // stable slugs so item.group survives renames. Absent/legacy = the classic // single To Do. Checked tasks all land in the one shared Done regardless. const TASK_GROUP_MAX = 10; function sanitizeTaskGroups(v) { const out = []; const seen = new Set(); if (Array.isArray(v)) { for (const g of v) { if (!g || typeof g !== 'object') continue; const name = String(g.name || '').trim().slice(0, 40); const id = String(g.id || '').trim().toLowerCase().slice(0, 40); if (!name || !/^[a-z0-9-]+$/.test(id) || seen.has(id)) continue; seen.add(id); out.push({ id, name }); if (out.length >= TASK_GROUP_MAX) break; } } // The default section always exists and always leads. const di = out.findIndex((g) => g.id === 'todo'); if (di < 0) out.unshift({ id: 'todo', name: 'To Do' }); else if (di > 0) out.unshift(out.splice(di, 1)[0]); return out; } // Snap every task's group to an EXISTING section (deleted/unknown → default), // so a task can never be stranded invisible — the same rule that keeps notes // and notes-only lists safe. Notes never carry a group. function reconcileItemGroups(list) { const ids = new Set((Array.isArray(list.taskGroups) ? list.taskGroups : []).map((g) => g && g.id)); (list.items || []).forEach((it) => { if (!it || typeof it !== 'object') return; if (it.kind === 'note') { delete it.group; return; } if (typeof it.group !== 'string' || !ids.has(it.group)) it.group = 'todo'; }); } function sanitizeColumnOrder(v) { const seen = new Set(); const out = []; if (Array.isArray(v)) { v.forEach((k) => { const s = String(k || ''); if (COLUMN_KEYS.includes(s) && !seen.has(s)) { seen.add(s); out.push(s); } }); } COLUMN_KEYS.forEach((k) => { if (!seen.has(k)) out.push(k); }); return out; } function sanitizeRecurrence(r) { if (!r || typeof r !== 'object') return null; // A task's recurrence now uses the SAME schedule shape + validation as a list's // reset schedule — type, every-N interval, start weekday, day/month, and intraday // times — so a single task has the exact same repeat options as a whole list. // Unknown/empty shape → treat as NOT recurring (rather than silently coercing a // stray {} into a daily repeat). The UI always sends a valid type when recurring. const allowed = ['daily', 'weekly', 'monthly', 'yearly']; if (!allowed.includes(r.type)) return null; return sanitizeResetSchedule(r); } // A list's optional auto-reset schedule (when its items return to To Do): // null = manual only; weekly on a weekday (0=Sun); or monthly on a date. // Most recent date on/before today whose weekday === dow (today if it matches). function mostRecentWeekday(dow) { const now = new Date(); const d = new Date(now.getFullYear(), now.getMonth(), now.getDate()); d.setDate(d.getDate() - ((d.getDay() - dow + 7) % 7)); return d.toDateString(); } // Normalize a list of intraday reset times ("HH:MM", 24h) → sorted, de-duped, // zero-padded, capped. Returns null when nothing valid (so the field is omitted // and the list keeps its plain once-per-day behavior). function sanitizeResetTimes(arr) { if (!Array.isArray(arr)) return null; const seen = new Set(); const out = []; for (const raw of arr) { const m = /^(\d{1,2}):(\d{2})$/.exec(String(raw).trim()); if (!m) continue; const h = parseInt(m[1], 10); const mn = parseInt(m[2], 10); if (h < 0 || h > 23 || mn < 0 || mn > 59) continue; const key = String(h).padStart(2, '0') + ':' + String(mn).padStart(2, '0'); if (seen.has(key)) continue; seen.add(key); out.push(key); } out.sort(); return out.length ? out.slice(0, 24) : null; } function sanitizeResetSchedule(s) { if (!s || typeof s !== 'object') return null; const interval = clampInt(s.interval, 1, 99, 1); // "every N" → biweekly = weekly+2, etc. let out; if (s.type === 'daily') { out = { type: 'daily', interval }; // "Every N days starting " — only meaningful when N>1. if (interval > 1) { const dow = clampInt(s.startDow, 0, 6, -1); if (dow >= 0) out.startDow = dow; } } else if (s.type === 'weekly') out = { type: 'weekly', interval, dayOfWeek: clampInt(s.dayOfWeek, 0, 6, 0) }; else if (s.type === 'monthly') out = { type: 'monthly', interval, dayOfMonth: clampInt(s.dayOfMonth, 1, 31, 1) }; else if (s.type === 'yearly') out = { type: 'yearly', interval, month: clampInt(s.month, 0, 11, 0), dayOfMonth: clampInt(s.dayOfMonth, 1, 31, 1) }; else return null; // Optional intraday repeat: reset at specific clock times on each due day. Only // offered for daily/weekly (the "within one day" cases); ignored for monthly/yearly. if (out.type === 'daily' || out.type === 'weekly') { const times = sanitizeResetTimes(s.times); if (times) out.times = times; } // An interval > 1 needs an anchor date to define the cadence. Preserve a valid // incoming anchor (so unchanged saves keep the rhythm). Otherwise: for a daily // schedule with a chosen start weekday, anchor on that weekday; else start today. // Editing the repeat in the modal sends no anchor, so it re-anchors as needed. if (out.interval > 1) { const a = s.anchor ? new Date(s.anchor) : null; if (a && !isNaN(a.getTime())) out.anchor = new Date(a.getFullYear(), a.getMonth(), a.getDate()).toDateString(); else if (out.type === 'daily' && out.startDow != null) out.anchor = mostRecentWeekday(out.startDow); else out.anchor = new Date().toDateString(); } return out; } // Build a clean item from client input; id + createdAt are assigned server-side. // CANONICAL task-list line regex. MUST stay byte-identical to md.js TASK_RE and app.js // parseTaskList — count/index/label must agree across all three or per-location checkmarks // misalign. Sub-steps are authored INLINE in item.description as "- [ ] label" lines. const TASK_LINE_RE = /^\s*[-*]\s+\[([ xX])\]\s*(.*)$/; // Ordered, whitespace-normalized checkbox labels in document order. Deliberately EXCLUDES // the [ ]/[x] mark char: runtime checked state lives only in the per-location check object, // so toggling a source mark must NOT count as a structural change. function taskLineLabels(description) { const out = []; String(description == null ? '' : description).replace(/\r\n?/g, '\n').split('\n').forEach((line) => { const m = TASK_LINE_RE.exec(line); if (m) out.push(m[2].trim().replace(/\s+/g, ' ')); }); return out; } function taskLineCount(description) { return taskLineLabels(description).length; } function taskSignature(description) { return JSON.stringify(taskLineLabels(description)); } // Drop empty-label checkbox lines ("- [ ]" with no text). An empty checkbox is meaningless // and, before this, a checkbox whose text was deleted in the editor lingered as a ghost box // in the live list. Applied on every description save so a deleted checkbox truly disappears. function stripEmptyTaskLines(description) { return String(description == null ? '' : description).replace(/\r\n?/g, '\n').split('\n') .filter((line) => { const m = TASK_LINE_RE.exec(line); return !(m && m[2].trim() === ''); }) .join('\n'); } // A task's checkbox lines changed (added/removed/reordered/renamed). Sub-step progress is // keyed by POSITION (index), so realign every location's marks to the NEW positions by // matching labels (first unused match) — this preserves progress across a reorder or a // prose edit and only drops the mark for a step whose label genuinely vanished. Then // recompute completion. Called ONLY when the signature actually changed. function remapItemSubChecks(state, listId, item, oldDescription) { const oldLabels = taskLineLabels(oldDescription); const newLabels = taskLineLabels(item.description); const total = newLabels.length; Object.keys(state.checks || {}).forEach((scope) => { const byList = state.checks[scope] && state.checks[scope][listId]; const rec = byList && byList[item.id]; if (!rec) return; const old = rec.subChecks || {}; if (total > 0) { const claimed = new Array(total).fill(false); const next = {}; Object.keys(old).forEach((k) => { const oi = Number(k); if (!Number.isInteger(oi) || oi < 0 || oi >= oldLabels.length) return; // stale/invalid key const label = oldLabels[oi]; for (let j = 0; j < total; j++) { if (!claimed[j] && newLabels[j] === label) { claimed[j] = true; next[String(j)] = old[k]; break; } } }); rec.subChecks = next; const doneCount = Object.keys(next).length; const nowComplete = doneCount === total; if (nowComplete && !rec.completed) { rec.completed = true; rec.completedDate = rec.completedDate || new Date().toISOString(); } else if (!nowComplete && rec.completed) { rec.completed = false; delete rec.completedDate; delete rec.completedBy; } if (!rec.completed && doneCount === 0) delete byList[item.id]; } else { // No checkboxes anymore → drop positional progress; a manual completed flag (if any) stays. delete rec.subChecks; if (!rec.completed) delete byList[item.id]; } }); } // One-time, idempotent migration of the legacy structured sub-step model (item.subtasks = // [{id,text}], subChecks keyed by subtaskId) to the inline model (task lines in the // description, subChecks keyed by index). Appends each step as a "- [ ]" line in order, // remaps every location's subChecks from id → 0-based index (preserving in-progress marks), // then deletes item.subtasks. Finally sweeps any leftover non-integer subCheck key (e.g. // orphaned st-* ids from deleted items). Safe to run on every load — a no-op once migrated. function migrateLegacySubtasks(state) { if (!state || !Array.isArray(state.lists)) return; state.checks = state.checks || {}; state.lists.forEach((list) => { (list.items || []).forEach((item) => { if (!item || !Array.isArray(item.subtasks)) return; const steps = []; for (const s of item.subtasks) { if (!s || typeof s !== 'object') continue; const text = String(s.text == null ? '' : s.text).trim(); if (!text) continue; steps.push({ id: String(s.id || '').trim(), text }); } if (steps.length) { const lines = steps.map((s) => '- [ ] ' + s.text).join('\n'); const desc = String(item.description || '').replace(/\s+$/, ''); item.description = desc ? desc + '\n\n' + lines : lines; const indexOfId = new Map(steps.map((s, i) => [s.id, i])); Object.keys(state.checks).forEach((scope) => { const byList = state.checks[scope] && state.checks[scope][list.id]; const rec = byList && byList[item.id]; if (!rec || !rec.subChecks) return; const remapped = {}; Object.keys(rec.subChecks).forEach((sid) => { if (indexOfId.has(sid)) remapped[String(indexOfId.get(sid))] = rec.subChecks[sid]; }); rec.subChecks = remapped; if (!rec.completed && Object.keys(remapped).length === 0) delete byList[item.id]; }); } delete item.subtasks; }); }); // Sweep leftover non-integer subCheck keys (old st-* ids on any record, incl. orphans). Object.keys(state.checks).forEach((scope) => { Object.keys(state.checks[scope] || {}).forEach((lid) => { Object.keys(state.checks[scope][lid] || {}).forEach((iid) => { const rec = state.checks[scope][lid][iid]; if (rec && rec.subChecks) { Object.keys(rec.subChecks).forEach((k) => { if (!/^\d+$/.test(k)) delete rec.subChecks[k]; }); if (!rec.completed && Object.keys(rec.subChecks).length === 0) delete state.checks[scope][lid][iid]; } }); }); }); } function buildItem(body) { const title = String((body && body.title) || '').trim(); if (!title) return null; // Sub-steps live inline in the description now. Fold any legacy body.subtasks (e.g. a // pre-pivot scoped import) into the description as "- [ ]" lines so nothing is lost. let description = String((body && body.description) || '').trim(); const legacy = Array.isArray(body && body.subtasks) ? body.subtasks : []; if (legacy.length) { const lines = legacy.map((s) => s && String(s.text || '').trim()).filter(Boolean).map((t) => '- [ ] ' + t).join('\n'); if (lines) description = description ? description + '\n\n' + lines : lines; } description = stripEmptyTaskLines(description); const kind = (body && body.kind === 'note') ? 'note' : 'task'; return { id: `${Date.now()}-${Math.random().toString(16).slice(2, 6)}`, title, description, kind, // Which task section it lives in; callers run reconcileItemGroups against the // target list so an unknown id can never strand the task invisible. ...(kind === 'note' ? {} : { group: String((body && body.group) || '').trim().toLowerCase().slice(0, 40) || 'todo' }), // A note never recurs (it has no completion to reset); force null so it can't drag its list into // the per-poll intraday-recurrence rescan (hasIntradayItem) or show a misleading repeat badge. recurrence: kind === 'note' ? null : sanitizeRecurrence(body && body.recurrence), createdAt: new Date().toISOString() }; } // Resolve a typed login value (which may be an abbreviation like "sc" or a full // name) against the admin-managed directory. Falls back to the typed value. function expandAbbrev(value, kind, directory) { const v = String(value || '').trim(); if (!v) return ''; const list = (directory && Array.isArray(directory[kind])) ? directory[kind] : []; const lower = v.toLowerCase(); const byAbbrev = list.find((e) => (e.abbrev || '').toLowerCase() === lower); if (byAbbrev) return byAbbrev.name; const byName = list.find((e) => (e.name || '').toLowerCase() === lower); if (byName) return byName.name; return v; } function escapeHtml(text) { return String(text == null ? '' : text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function resetAllItems(state) { const today = new Date().toDateString(); state.lists.forEach((list) => { // Lists with their own auto-reset schedule are governed by that schedule // (applyRecurrenceResets), so the global weekly digest reset leaves them alone. if (list.resetSchedule) return; Object.keys(state.checks || {}).forEach((scope) => { if (state.checks[scope] && state.checks[scope][list.id]) delete state.checks[scope][list.id]; }); list.lastCheckDate = today; }); } // Send the report NOW, scoped to what `user` may send (see teamsInScopeWithRecipients). // dryRun returns the planned recipients without sending (and without needing SMTP). async function sendScopedReport(user, { dryRun = false } = {}) { const state = await getState(); const now = new Date(); const subjectDate = now.toLocaleDateString('en-US', { timeZone: REPORT_TZ, weekday: 'long', month: 'short', day: 'numeric' }); const targets = []; if (isMasterAdmin(user) && REPORT_EMAIL) { targets.push({ kind: 'global', to: REPORT_EMAIL, subject: `[Manual] Checklist Report — ${subjectDate}`, build: () => buildReport(state) }); } for (const acct of teamsInScopeWithRecipients(state, user)) { const label = teamLabel(acct); targets.push({ kind: 'team', team: label, to: acct.emailRecipients, subject: `[Manual] ${label} — Checklist ${subjectDate}`, build: () => buildTeamReport(state, acct) }); } if (!targets.length) { throw new Error('No report recipients configured for your area. Add report email(s) to the team login(s) in Manage Access.'); } if (dryRun) { return { ok: true, dryRun: true, sentCount: targets.length, sends: targets.map((t) => ({ kind: t.kind, team: t.team || null, to: t.to })) }; } const transporter = buildTransporter(); if (!transporter) throw new Error('SMTP is not configured (SMTP_HOST, SMTP_USER, SMTP_PASS)'); const sends = []; for (const t of targets) { const report = t.build(); await transporter.sendMail({ from: SMTP_FROM, to: Array.isArray(t.to) ? t.to.join(', ') : t.to, subject: t.subject, text: report.text || '(no tasks)', html: report.html || '

(no tasks)

' }); sends.push({ kind: t.kind, team: t.team || null, to: t.to, done: report.totalDone, missed: report.totalMissed }); } return { ok: true, sentCount: sends.length, sends }; } async function sendDigestEmail({ manualTrigger = false } = {}) { const transporter = buildTransporter(); if (!transporter) { throw new Error('SMTP is not configured (SMTP_HOST, SMTP_USER, SMTP_PASS)'); } const state = await getState(); const now = new Date(); const subjectDate = now.toLocaleDateString('en-US', { timeZone: REPORT_TZ, weekday: 'long', month: 'short', day: 'numeric' }); const prefix = manualTrigger ? '[Manual] ' : ''; const sends = []; // 1) Global digest to REPORT_EMAIL — unchanged behavior. Optional now. if (REPORT_EMAIL) { const report = buildReport(state); await transporter.sendMail({ from: SMTP_FROM, to: REPORT_EMAIL, subject: `${prefix}Checklist Report — ${subjectDate}`, text: report.text || '(checklist was empty)', html: report.html || '

(checklist was empty)

' }); sends.push({ kind: 'global', to: REPORT_EMAIL, done: report.totalDone, missed: report.totalMissed }); console.log(`[digest] Global sent to ${REPORT_EMAIL} — done: ${report.totalDone}, missed: ${report.totalMissed}`); } // 2) One email per team that has recipients configured. for (const acct of teamsWithRecipients(state)) { const report = buildTeamReport(state, acct); const label = teamLabel(acct); await transporter.sendMail({ from: SMTP_FROM, to: acct.emailRecipients.join(', '), subject: `${prefix}${label} — Checklist ${subjectDate}`, text: report.text || '(no tasks)', html: report.html || '

(no tasks)

' }); sends.push({ kind: 'team', team: label, to: acct.emailRecipients, done: report.totalDone, missed: report.totalMissed }); console.log(`[digest] Team "${label}" sent to ${acct.emailRecipients.join(', ')} — done: ${report.totalDone}, missed: ${report.totalMissed}`); } if (sends.length === 0) { throw new Error('No recipients configured. Set REPORT_EMAIL or add team recipients in Manage Users.'); } // Reset EXACTLY ONCE, after every email is sent (scheduled runs only). let didReset = false; if (RESET_AFTER_DIGEST && !manualTrigger) { resetAllItems(state); await saveState(state); didReset = true; console.log('[digest] Reset all items for next week'); } return { ok: true, sentCount: sends.length, sends, didReset }; } // Schedule the weekly digest if (cron.validate(REPORT_CRON)) { cron.schedule( REPORT_CRON, () => { sendDigestEmail({ manualTrigger: false }).catch((err) => { console.error('[digest] Failed:', err.message); }); }, { timezone: REPORT_TZ } ); console.log(`[cron] Digest scheduled: "${REPORT_CRON}" (${REPORT_TZ}) → ${REPORT_EMAIL || ''}`); } else { console.warn(`[cron] Invalid REPORT_CRON "${REPORT_CRON}", digest disabled`); } // Daily cleanup of orphaned equipment photos (4:00 AM local). cron.schedule('0 4 * * *', () => { sweepOrphanUploads(); sweepOrphanDocs(); pruneOldRequests(); }, { timezone: REPORT_TZ }); // Nightly data backup (3:30 AM local), plus one shortly after startup so there's // always a recent snapshot even between scheduled runs / right after a deploy. cron.schedule('30 3 * * *', () => { backupState(BACKUP_KEEP); backupMedia(BACKUP_KEEP); }, { timezone: REPORT_TZ }); setTimeout(() => { backupState(BACKUP_KEEP); backupMedia(BACKUP_KEEP); }, 10000); // ─── Start ─────────────────────────────────────────────────────── loadState().then((state) => { cachedState = state; app.listen(PORT, () => { console.log(`Recurring Checklist server listening on http://0.0.0.0:${PORT}`); }); }).catch((err) => { console.error('Fatal: failed to load state', err); process.exit(1); });