3833 lines
190 KiB
JavaScript
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>
|
||
|
|
· ${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 ? ` · ${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, '&')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, ''');
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetAllItems(state) {
|
||
|
|
const today = new Date().toDateString();
|
||
|
|
state.lists.forEach((list) => {
|
||
|
|
// Lists with their own auto-reset schedule are governed by that schedule
|
||
|
|
// (applyRecurrenceResets), so the global weekly digest reset leaves them alone.
|
||
|
|
if (list.resetSchedule) return;
|
||
|
|
Object.keys(state.checks || {}).forEach((scope) => {
|
||
|
|
if (state.checks[scope] && state.checks[scope][list.id]) delete state.checks[scope][list.id];
|
||
|
|
});
|
||
|
|
list.lastCheckDate = today;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Send the report NOW, scoped to what `user` may send (see teamsInScopeWithRecipients).
|
||
|
|
// dryRun returns the planned recipients without sending (and without needing SMTP).
|
||
|
|
async function sendScopedReport(user, { dryRun = false } = {}) {
|
||
|
|
const state = await getState();
|
||
|
|
const now = new Date();
|
||
|
|
const subjectDate = now.toLocaleDateString('en-US', { timeZone: REPORT_TZ, weekday: 'long', month: 'short', day: 'numeric' });
|
||
|
|
const targets = [];
|
||
|
|
if (isMasterAdmin(user) && REPORT_EMAIL) {
|
||
|
|
targets.push({ kind: 'global', to: REPORT_EMAIL, subject: `[Manual] Checklist Report — ${subjectDate}`, build: () => buildReport(state) });
|
||
|
|
}
|
||
|
|
for (const acct of teamsInScopeWithRecipients(state, user)) {
|
||
|
|
const label = teamLabel(acct);
|
||
|
|
targets.push({ kind: 'team', team: label, to: acct.emailRecipients, subject: `[Manual] ${label} — Checklist ${subjectDate}`, build: () => buildTeamReport(state, acct) });
|
||
|
|
}
|
||
|
|
if (!targets.length) {
|
||
|
|
throw new Error('No report recipients configured for your area. Add report email(s) to the team login(s) in Manage Access.');
|
||
|
|
}
|
||
|
|
if (dryRun) {
|
||
|
|
return { ok: true, dryRun: true, sentCount: targets.length, sends: targets.map((t) => ({ kind: t.kind, team: t.team || null, to: t.to })) };
|
||
|
|
}
|
||
|
|
const transporter = buildTransporter();
|
||
|
|
if (!transporter) throw new Error('SMTP is not configured (SMTP_HOST, SMTP_USER, SMTP_PASS)');
|
||
|
|
const sends = [];
|
||
|
|
for (const t of targets) {
|
||
|
|
const report = t.build();
|
||
|
|
await transporter.sendMail({
|
||
|
|
from: SMTP_FROM,
|
||
|
|
to: Array.isArray(t.to) ? t.to.join(', ') : t.to,
|
||
|
|
subject: t.subject,
|
||
|
|
text: report.text || '(no tasks)',
|
||
|
|
html: report.html || '<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);
|
||
|
|
});
|