Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
367 lines
18 KiB
JavaScript
367 lines
18 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
|
|
|
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
|
|
},
|
|
|
|
remove: function (id) {
|
|
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
|
.then(function () { cacheRemove(id); })
|
|
.catch(function () { cacheRemove(id); });
|
|
},
|
|
|
|
// ── 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.
|
|
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) { markDead(op.opId, 'HTTP ' + status); }
|
|
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) ──────────────────────
|
|
function syncCounts() {
|
|
var q = qRead(), pending = 0, failed = 0;
|
|
for (var i = 0; i < q.length; i++) {
|
|
if (q[i].dead || (q[i].tries || 0) >= 3) failed++; else pending++;
|
|
}
|
|
return { pending: pending, failed: failed, 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; }
|
|
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);
|