Files
Project-SDE-WP-Suite/html/project-data.js
n.siegfried 928ab8c900 Archive projects, auto-add default members, rebuild the admin console
Three things asked for together, plus the migration they share (a7c31f9e5b02 —
additive, with database defaults for existing rows, so unlike the users.role
rewrite it is safe under a code-only rollback).

ARCHIVE A PROJECT. A finished job leaves every picker, switcher and search, and
freezes read-only, without losing anything. Hiding is free: GET /api/projects
defaults to archived=exclude, so the home picker and the app-bar switcher drop it
without either of them changing. Freezing is require_project_writable(), which
every write that lands on a project now goes through — SOP and WP upserts (both
ends, so a package can be moved neither into nor out of an archived job), deletes,
issue, status, WP archive, and comments on its WPs/SOPs. It answers 409, not 403:
nobody lacks a permission, the project's state is the objection, and the browser
outbox in project-data.js retires 4xx ops instead of retrying them against a job
that will never accept them. Unarchive and delete stay allowed on purpose —
unarchive is the one write an archived project must take, and archive-then-delete
is a normal sequence.

DEFAULT MEMBERS ON NEW PROJECTS. users.auto_add_projects / auto_add_role flag the
people who belong on every job, so an admin says it once instead of remembering it
at each project creation. It runs on the is_new branch of upsert_project, which is
the single road into project creation, so the home page, the sample project and the
demo seeder are all covered and an update never re-runs it. Note the interaction
with the existing creator-grant: that row commits first and add_default_members
never overwrites an existing membership, so the creator grant now carries the
creator's own auto_add_role — otherwise someone flagged "Project Admin on every
job" would land as a plain member on the one job they started themselves.

ADMIN CONSOLE. The user table had outgrown .wrap{max-width:860px}: nine columns in
an 860px card meant every cell wrapped, so one user occupied a ~100px band, the
action buttons stacked, and the table spilled outside its own white card. Now
1240px, with wide tables scrolling inside .tscroll so the page itself never scrolls
sideways, and one spacing/control scale across all twelve cards. Truncation hangs
off a span inside the cell rather than max-width on the td, which table-layout:auto
treats as advisory — the usual reason cell ellipsis works in the stylesheet and not
on the page.

Found in review and fixed here rather than later:

- Stored XSS in the new Projects card, reachable by any signed-in user, landing in
  an admin's session. The uesc(v).replace(/'/g,"\'") idiom this file already used
  in eight places escapes in the wrong order — uesc leaves backslashes alone, so a
  stored name containing \' closes the JS string literal and the rest executes.
  jsq() does backslash, then quote, then HTML, and all thirteen handler bindings go
  through it. The same bug, unescaped entirely, was in the SOP builder's custom
  constraint names (escHandlerArg there). Three of seven test payloads escaped the
  literal under the old idiom — one of them a plain name ending in a backslash, so
  it was breaking buttons for innocent input too.
- _save_comment resolved wp_id and sop_id with if/elif but stored both, so a
  payload naming a WP you may touch and a SOP you may not was authorised on the WP
  alone and still wrote into the other project's thread. Both are checked now.
- Promoting an account to admin left its default-member flag set but invisible,
  ready to take effect again on demotion — cleared, as set_user_auto_add already
  does for the role.

smoketest.py and the console's own smoke test both assert the archive round trip:
out of the default list, present with archived=all, writes refused with 409, and
all of it undone by unarchiving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:48:43 -07:00

404 lines
21 KiB
JavaScript

/* Shared project layer for the Work Package Suite.
Projects are the top-level container — every SOP and Work Package belongs to
one. Project records live in the SQL database (via /api/projects); this
adapter is API-first and falls back to a localStorage mirror so the suite
still works in local dev / offline. Included by the home page and the suite. */
(function (global) {
'use strict';
var API = '/api';
var LS_PROJECTS = 'wp_projects'; // local mirror of the project list
var LS_ACTIVE = 'wp_active_project'; // active project id
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
function cacheUpsert(p) {
var list = readLocal();
var ix = list.findIndex(function (x) { return x.id === p.id; });
if (ix >= 0) list[ix] = p; else list.unshift(p);
writeLocal(list);
}
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
var SAMPLE_PROJECT = {
name: 'Micron FMCS Install (sample)', number: '26-67-008',
client: 'Micron Technology, Inc.', division: 'Semiconductor',
site: 'Boise, ID — Fab', sample: true
};
var ProjectData = {
SAMPLE: SAMPLE_PROJECT,
esc: esc,
// Returns the project list. Tries the API; falls back to the local mirror.
list: function () {
return fetch(API + '/projects', { headers: { 'Accept': 'application/json' } })
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (rows) { writeLocal(rows); return rows; })
.catch(function () { return readLocal(); });
},
get: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id))
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.catch(function () { return readLocal().find(function (x) { return x.id === id; }) || null; });
},
// Create or update. Assigns an id when new. Mirrors to localStorage either way.
save: function (p) {
if (!p.id) p.id = uid();
return fetch(API + '/projects', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p)
})
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (saved) { cacheUpsert(saved); return saved; })
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
},
// Deleting a project cascades its SOPs and work packages, and the server
// allows it only for a Project Admin. Drop it from the local cache ONLY if
// the server actually deleted it (or it was already gone) — removing it on a
// 403 would hide a project that still exists for everyone else.
remove: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function (r) {
if (r.ok || r.status === 404) { cacheRemove(id); return true; }
return r.json().catch(function () { return null; }).then(function (j) {
throw new Error((j && j.detail) || ('Could not delete the project (HTTP ' + r.status + ').'));
});
});
},
// Archiving a project lives in the admin console (html/admin.js), which doesn't
// load this file — deliberately not mirrored here, so there's only one
// implementation of it rather than two that can disagree.
// ── active project context ────────────────────────────────────────────────
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
setActive: function (p) {
try {
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
} catch (e) {}
},
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
// key('wp_iwp_v1') → 'wp_iwp_v1__proj_ab12'
// Falls back to the bare key when no project is active.
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
};
// ── Server sync for SOPs and Work Packages ─────────────────────────────────
// SOPs and WPs are authoritative on the server (so every user of a project sees
// the same data). To avoid rewriting the two apps, we keep their existing
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact
// keys from the API on page load, and the push* helpers write through to the
// API whenever the apps save. The apps' own (synchronous) reads are unchanged.
// (Original author: C-West8, "storing data in DB instead of client only";
// reintegrated on top of the BIM/per-package work.)
function nsKey(base, id) { return id ? base + '__' + id : base; }
function currentUser() {
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
}
// A saved Work Package is a flat object in the browser; the API splits it into
// promoted columns + a `data` blob. We store the whole flat object in `data`
// for perfect round-tripping (so BIM fields, kind, projectLinks, etc. all
// survive), and mirror the few fields the API promotes to columns.
function pkgToServer(p, projectId) {
return {
id: p.id,
project_id: p.projectId || projectId || null,
parent_id: p.instanceOf || null,
number: p.number || '',
subject: p.subject || '',
type: p.type || '',
status: p.status || 'Draft',
assignee_id: p.assigneeId || null,
created_by: p.createdBy || currentUser(),
data: p
};
}
function serverToPkg(row) {
var p = Object.assign({}, row.data || {}); // full flat object lives in data
p.id = row.id;
p.projectId = row.project_id || p.projectId || '';
if (row.number) p.number = row.number;
if (row.subject != null) p.subject = row.subject;
if (row.type != null) p.type = row.type;
if (row.status) p.status = row.status; // honor server-side status changes
if (row.parent_id) p.instanceOf = row.parent_id;
p.archived = !!row.archived_at;
p.assigneeId = row.assignee_id || '';
return p;
}
// Pull this project's SOP + WPs from the API into the localStorage keys the
// apps read. Resolves even on failure (offline / no API) so boot continues.
ProjectData.pullProject = function (projectId) {
if (!projectId) return Promise.resolve();
var jobs = [];
jobs.push(
fetch(API + '/sops/latest?complete=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (sopRow) {
if (sopRow && sopRow.data) {
var d = sopRow.data; // { sop, state } as written by pushSOP
if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop));
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
}
}).catch(function () {})
);
jobs.push(
fetch(API + '/wps?full=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (rows) {
if (Array.isArray(rows)) {
localStorage.setItem(nsKey('wp_iwp_v1', projectId), JSON.stringify(rows.map(serverToPkg)));
}
}).catch(function () {})
);
return Promise.all(jobs).then(function () {});
};
// ── Durable write-through outbox ───────────────────────────────────────────
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a
// silently-failed POST leaves the browser and server divergent. Instead of a
// fire-and-forget request, each mutation is appended to a localStorage-backed
// queue and flushed to the API with retry + backoff. The API upserts by id and
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
// local cache still updates immediately, so rendering never waits on the network.
var OUTBOX_KEY = 'wp_sync_outbox_v1';
var _flushTimer = null, _backoff = 0, _flushing = false;
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
// Append an op, coalescing by (kind,key) so only the latest write per entity is
// queued. A delete supersedes any pending upsert for the same id.
function enqueue(op) {
var q = qRead();
if (op.kind === 'wp-del') {
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
} else {
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
}
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
op.tries = 0;
q.push(op);
qWrite(q);
notifySync();
scheduleFlush(0);
}
function opRequest(op) {
if (op.kind === 'wp-del') {
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
}
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
});
}
function bumpTries(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
qWrite(q);
}
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
// retrying, so a rejected write can't loop forever. `err` is the server's own
// explanation when it sent one; it is what the sync badge shows the user.
function markDead(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
qWrite(q);
}
// Attempt every live op; successes are removed, 4xx client errors are marked
// dead (won't succeed on retry), transient failures (429/5xx/network) stay queued.
function flush() {
if (_flushing) return Promise.resolve();
var q = qRead().filter(function (o) { return !o.dead; });
if (!q.length) { notifySync(); return Promise.resolve(); }
_flushing = true; notifySync();
var chain = Promise.resolve(), anyFail = false;
q.forEach(function (op) {
chain = chain.then(function () {
return opRequest(op).then(function (r) {
var status = r ? r.status : 0;
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
else if (status >= 400 && status < 500 && status !== 429) {
// Refused once, refused forever — so the only useful thing left is the
// reason. A 409 here is the archived-project gate, whose detail tells
// the user the project is read-only and how to get it unarchived; a
// bare "HTTP 409" would leave them staring at a change that vanished.
return r.json().catch(function () { return null; }).then(function (j) {
var why = (j && typeof j.detail === 'string' && j.detail) || ('HTTP ' + status);
markDead(op.opId, why);
});
}
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
});
});
return chain.then(function () {
_flushing = false;
notifySync();
if (qRead().filter(function (o) { return !o.dead; }).length) {
_backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0;
scheduleFlush(_backoff || 15000);
} else { _backoff = 0; }
});
}
function scheduleFlush(delay) {
if (_flushTimer) return; // one pending flush at a time
_flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0);
}
// ── sync status (drives the indicator + any listeners) ──────────────────────
// `failed` stays the total not-getting-through count (what listeners already
// read); `dead` splits out the ops the server has permanently refused, with the
// first reason it gave, because those two states need different words.
function syncCounts() {
var q = qRead(), pending = 0, failed = 0, dead = 0, reason = '';
for (var i = 0; i < q.length; i++) {
if (q[i].dead) { dead++; if (!reason && q[i].lastErr) reason = String(q[i].lastErr); }
else if ((q[i].tries || 0) >= 3) failed++;
else pending++;
}
return { pending: pending, failed: failed + dead, dead: dead, reason: reason, syncing: _flushing };
}
ProjectData.syncStatus = syncCounts;
function notifySync() {
var c = syncCounts();
try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {}
renderSyncBadge(c);
}
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
// isn't duplicated inside the embedded creator iframe; the top window still sees
// the iframe's queue changes via the 'storage' event below.
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
var _badgeHideTimer = null;
function renderSyncBadge(c) {
if (!_isTop || !document.body) return;
var el = document.getElementById('wp-sync-badge');
if (!el) {
el = document.createElement('div');
el.id = 'wp-sync-badge';
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
document.body.appendChild(el);
}
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
// A dead op is a refusal, not a hiccup — "retrying" would be a lie, and the
// reason is the only thing that tells the user what to do (e.g. the project is
// archived). Stack it under the headline; the badge never hides in this state.
el.style.flexDirection = c.dead ? 'column' : 'row';
el.style.alignItems = c.dead ? 'flex-start' : 'center';
el.style.maxWidth = c.dead ? 'min(340px, calc(100vw - 32px))' : 'none';
if (c.dead) {
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — not saved</span>' +
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
el.style.color = '#a2191f'; el.style.borderColor = '#ffd7d9'; el.style.background = '#fff1f1'; el.style.display = 'inline-flex';
} else if (c.failed) {
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
} else if (c.pending) {
el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…';
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
} else {
el.textContent = '✓ All changes saved';
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
}
}
// Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and
// a periodic backstop. Anything left from a previous session flushes on load.
try {
window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); });
window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } });
document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); });
setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000);
} catch (e) {}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); }
else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); }
// ── public write API (now durable via the outbox) ───────────────────────────
// Write a completed SOP (plus the builder's raw state). Deterministic id per
// project so re-completing updates the same row.
ProjectData.pushSOP = function (projectId, sop, state) {
if (!projectId) return Promise.resolve(null);
enqueue({
kind: 'sop', key: 'sop__' + projectId,
body: {
id: 'sop__' + projectId, project_id: projectId,
name: (sop && sop.project && sop.project.name) || 'SOP',
number: (sop && sop.project && sop.project.number) || '',
complete: true, created_by: currentUser(), data: { sop: sop, state: state }
}
});
return Promise.resolve(true);
};
// Upsert a single Work Package. The local cache stays the source of truth for
// immediate rendering; the outbox guarantees the write reaches the server.
ProjectData.pushWP = function (p, projectId) {
if (!p || !p.id) return Promise.resolve(null);
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) });
return Promise.resolve(true);
};
ProjectData.removeWP = function (id) {
if (!id) return Promise.resolve();
enqueue({ kind: 'wp-del', key: id });
return Promise.resolve(true);
};
// Force a flush now and resolve when the queue drains (or a round-trip is done).
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
// Archive / unarchive a Work Package (hide from active lists without deleting).
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
// the caller updates the view on the returned result.
ProjectData.archiveWP = function (id, archived) {
if (!id) return Promise.resolve(null);
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
// "show archived" view. Returns app-shaped package objects (p.archived === true).
ProjectData.listArchived = function (projectId) {
if (!projectId) return Promise.resolve([]);
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
.catch(function () { return []; });
};
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
// global state can't leak across projects. (User chose: discard, don't migrate.)
try {
if (!localStorage.getItem('wp_ns_migrated_v1')) {
['wp_suite_sop', 'wp_suite_state', 'wp_suite_sop_complete', 'wp_iwp_v1'].forEach(function (k) {
try { localStorage.removeItem(k); } catch (e) {}
});
localStorage.setItem('wp_ns_migrated_v1', '1');
}
} catch (e) {}
global.ProjectData = ProjectData;
})(window);