Files
recurring-checklist/server/server.js
T
Waters ChurchandClaude Opus 5 469db5bc60 Bring the checklist app under version control
The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:00:57 -04:00

3833 lines
190 KiB
JavaScript

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:'<url>', 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/<name>),
// 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/<sub>/
// back into data/<sub>/ (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/<name> — 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 = `<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;">
<div style="background:#a8071a;color:#fff;padding:12px 16px;border-radius:8px 8px 0 0;font-size:18px;font-weight:700;">⚠ Gear Down — needs attention now</div>
<div style="border:1px solid #f0d0d0;border-top:none;border-radius:0 0 8px 8px;padding:16px;">
<table cellpadding="4" style="font-size:14px;color:#222;">
<tr><td style="color:#888;">Gear</td><td><strong>${escapeHtml(gear)}</strong></td></tr>
<tr><td style="color:#888;">Where</td><td>${escapeHtml(scope)}</td></tr>
<tr><td style="color:#888;">Reported by</td><td>${escapeHtml(inc.by)} · ${escapeHtml(when)}</td></tr>
${inc.what ? `<tr><td style="color:#888;vertical-align:top;">Problem</td><td><strong>${escapeHtml(inc.what)}</strong></td></tr>` : ''}
</table>
${inc.details ? `<div style="margin-top:10px;padding:10px;background:#faf3f3;border-radius:6px;color:#444;font-size:13px;white-space:pre-wrap;">${escapeHtml(inc.details)}</div>` : ''}
<p style="margin-top:14px;color:#999;font-size:12px;">Automatic alert from the checklist app — this gear was flagged as unusable right now.</p>
</div></div>`;
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(`<h1 style="margin:32px 0 8px;color:#1a1a1a;background:#f1c40f;padding:8px 12px;border-radius:6px;">${escapeHtml(title)}</h1>`);
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(`<h2 style="margin:18px 0 8px;color:#111;border-bottom:2px solid #3498db;padding-bottom:4px;">${escapeHtml(list.name)}</h2>`);
html.push(`<table cellpadding="6" cellspacing="0" style="width:100%;border-collapse:collapse;font-family:-apple-system,Segoe UI,sans-serif;font-size:14px;">`);
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(`
<tr style="background:${bg};">
<td style="width:24px;font-size:18px;color:${color};font-weight:bold;">${symbol}</td>
<td style="color:#111;"><strong>${escapeHtml(item.title)}</strong>${item.description ? `<div style="color:#555;font-size:12px;margin-top:2px;">${escapeHtml(item.description)}</div>` : ''}</td>
<td style="color:#555;text-align:right;white-space:nowrap;">${escapeHtml(who)}</td>
</tr>`);
if (isDone) listDone += 1;
else listMissed += 1;
});
html.push(`</table>`);
lines.push('');
lines.push(`Completed: ${listDone}/${listDone + listMissed} Missed: ${listMissed}`);
lines.push('');
html.push(`<p style="color:#555;font-size:13px;margin:4px 0 16px;">Completed ${listDone}/${listDone + listMissed} · Missed ${listMissed}</p>`);
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: `<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:720px;margin:0 auto;padding:16px;">${html.join('')}</div>`,
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 = [`<h1 style="margin:32px 0 8px;color:#fff;background:#a8071a;padding:8px 12px;border-radius:6px;">Open gear issues (${open.length}${down ? ` · ${down} down` : ''})</h1>`,
`<table cellpadding="6" cellspacing="0" style="width:100%;border-collapse:collapse;font-family:-apple-system,Segoe UI,sans-serif;font-size:14px;">`];
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(`<tr style="background:${bg};">
<td style="white-space:nowrap;font-weight:bold;color:${inc.severity === 'down' ? '#a8071a' : '#8a6d00'};">${sev}</td>
<td style="color:#111;"><strong>${escapeHtml(gear)}</strong> <span style="color:#888;">${escapeHtml(scope)}</span>${desc ? `<div style="color:#555;font-size:12px;margin-top:2px;">${escapeHtml(desc)}</div>` : ''}</td>
<td style="color:#555;text-align:right;white-space:nowrap;">${escapeHtml(inc.by)}<br><span style="font-size:11px;">${escapeHtml(formatTime(inc.ts))}</span></td>
</tr>`);
});
html.push('</table>');
return {
text: (report.text || '') + '\n' + lines.join('\n'),
html: (report.html || '').replace(/<\/div>\s*$/, '') + html.join('') + '</div>',
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 = `<div style="margin:0 0 16px;padding:12px 16px;background:#0f1b2d;border:1px solid #24405f;border-radius:8px;font-family:-apple-system,Segoe UI,sans-serif;">
<div style="font-size:16px;font-weight:700;color:#dbe6f2;">${emoji} Readiness</div>
<div style="margin-top:6px;font-size:14px;color:#c3d0de;">
<strong style="color:${pctColor};">${total ? `${done}/${total} tasks (${pct}%)` : 'No tasks tracked'}</strong>
&nbsp;·&nbsp; ${open.length ? `${open.length} open gear issue${open.length === 1 ? '' : 's'}${down ? ` <span style="color:#ff8a8d;font-weight:700;">(${down} down)</span>` : ''}` : 'No open gear issues'}
${newThisWeek ? `&nbsp;·&nbsp; ${newThisWeek} new this week` : ''}
</div></div>`;
// 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(`<h3 style="margin:14px 0 4px;color:#333;">${escapeHtml(list.name)}</h3><ul style="margin:0 0 10px;padding-left:18px;color:#444;font-size:13px;">`
+ notes.map((n) => `<li><strong>${escapeHtml(n.title)}</strong>${n.description ? `: ${escapeHtml(String(n.description).replace(/\s+/g, ' ').slice(0, 200))}` : ''}</li>`).join('') + `</ul>`);
});
let notesText = '';
let notesHtml = '';
if (noteText.length) {
notesText = '\n\n######## NOTES ########\n\n' + noteText.join('\n');
notesHtml = `<h1 style="margin:28px 0 8px;color:#111;background:#eaeef7;padding:8px 12px;border-radius:6px;">Notes</h1>` + noteHtml.join('');
}
const text = headText + (report.text || '') + notesText;
let html = report.html || '';
html = html.replace(/^(<div[^>]*>)/, '$1' + headHtml); // prepend readiness header inside the wrapper
html = html.replace(/<\/div>\s*$/, notesHtml + '</div>'); // 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 <weekday>" — 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 || '<p>(no tasks)</p>'
});
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 || '<p>(checklist was empty)</p>'
});
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 || '<p>(no tasks)</p>'
});
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 || '<unconfigured>'}`);
} 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);
});