diff --git a/docker-compose.yml b/docker-compose.yml
index 05e3df7..d4fd4f2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,12 +35,22 @@ services:
# default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
+ # Optional — read-only SQL Server connection to the Micron asset catalog,
+ # which backs the asset picker in the work package creator. Leave unset and
+ # the picker cleanly falls back to manual entry (see server/assets_db.py).
+ # Use a db_datareader login: the app only ever SELECTs.
+ MICRON_DB_URL: ${MICRON_DB_URL:-}
restart: unless-stopped
depends_on:
db:
condition: service_healthy # waits for postgres to accept connections
networks:
- internal
+ # Reaching the Micron database means leaving this compose project, and
+ # `internal` is deliberately egress-free. `outbound` is attached to the api
+ # container ONLY — the database and backup containers stay sealed. Detach it
+ # again if you are not using the Micron asset picker.
+ - outbound
db:
image: postgres:16-alpine
@@ -98,4 +108,12 @@ networks:
name: proxy
external: true
internal:
- internal: true # no outbound internet access from api/db
\ No newline at end of file
+ internal: true # no route off the host for anything on this network alone
+ outbound:
+ # An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
+ # above removes the gateway entirely, which blocks not just the internet but
+ # the LAN and the VPN too — so the api container needs this second network to
+ # reach the Micron asset database. Attached to `api` alone: `db` and `backup`
+ # remain on `internal` only and still have no way off the host.
+ # Detach it from api if you are not using the Micron asset picker.
+ driver: bridge
\ No newline at end of file
diff --git a/html/help.js b/html/help.js
index dc0a901..8554462 100644
--- a/html/help.js
+++ b/html/help.js
@@ -141,7 +141,7 @@
Key fields
Subject / Title (required) and WP Type (required, from the SOP).
-
Assets — link each controls.dev asset the package covers.
+
Assets — search the Micron DB by asset ID and add each asset the package covers. Anything not in the Micron DB can still be typed in by hand.
Scope & Work — the sequenced steps the crew performs (per-discipline in multi-discipline mode).
Labor – Est. Hrs. — drives the sizing check (see Sizing).
@@ -281,7 +281,7 @@
Sequence
SOP-defined construction phases; a WP can name a predecessor step.
Bagged & tagged
Materials on site, kitted, and labelled — part of the Materials constraint.
MIMO
Material In / Material Out — kitting and staging logistics.
-
Asset
A controls.dev record (equipment/system) a package is built around.
+
Asset
An asset ID from the Micron DB that a package is built around. The Micron DB is read-only here — picking an asset never changes it.
Hold / Witness point
Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.
Active project
The currently selected project; all data is scoped to it.
` },
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js
index a5480a7..cf04bce 100644
--- a/html/wp-creation-app.js
+++ b/html/wp-creation-app.js
@@ -105,7 +105,7 @@ function applySOP(){
applyKind();
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
- if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
+ if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
updateNumber(); updateReleaseBanner();
}
@@ -134,7 +134,7 @@ function applyKindVisibility(){
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
show('kind-row', bimProj);
show('bim-card', ewp); // model area / clash + IFF # / scan
- show('asset-card', !ewp); // controls.dev assets
+ show('asset-card', !ewp); // Micron DB assets
show('material-card', !ewp); // bill of materials
show('mimo-card', !ewp); // kitting / MIMO
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
@@ -807,20 +807,344 @@ function splitByDiscipline(){
alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+', '+(c.materials?c.materials.length:0)+' material line'+((c.materials&&c.materials.length===1)?'':'s')+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up.'+matNote);
}
-// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
-// Interim: assets are linked manually back to controls.dev. A future direct
-// integration will let the user pick them from a list instead of pasting links.
+// ── ASSETS (Micron asset catalog) ────────────────────────────────────────────
+// Assets are picked from the Micron asset catalog, a SQL Server database this app
+// reads through /api/assets. The lookup is strictly read-only — picking an asset
+// never writes to the catalog, and there is no endpoint that could.
+//
+// The catalog can be absent (not configured) or unreachable (VPN/host down), and
+// neither may block someone from writing a work package: in both cases the picker
+// says so and manual entry carries on. Manually entered assets are marked
+// source:'manual' so it stays visible which rows the catalog vouches for.
+// The whole catalog is fetched once when the page loads and searched in memory —
+// it is slow-moving reference data, so a request per keystroke would buy nothing
+// and cost latency on every one.
+let assetCatalog = []; // the full catalog, loaded once
+let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing
+let assetCatalogState = 'loading'; // loading | ready | absent | error
+let assetResults = []; // current matches; the result list indexes into this
+const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search
+const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets()
+
+function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); }
+function assetAlreadyAdded(tag){
+ const k = String(tag||'').trim().toLowerCase();
+ return pkgAssets.some(a => assetKey(a) === k && k);
+}
+
function buildAssets(){
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
- pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
- tr.innerHTML=`
No assets yet — search the Micron DB above to add the assets this package covers.
`;
+ return;
+ }
+ pkgAssets.forEach((a,i)=>{
+ const tr=document.createElement('tr');
+ // The asset ID on a catalog row is shown as text, not an input: the catalog
+ // is the source of truth for it and a locally edited copy would silently
+ // disagree. The note is always the user's own, so it stays editable either
+ // way. Every interpolation below is esc()'d text content or a numeric index
+ // — never a raw string inside an inline handler, which is the bug pattern
+ // recorded in KNOWN-ISSUES.md §1.
+ const idCell = a.source === 'catalog'
+ ? `
${esc(a.tag)}Micron DB
`
+ : `
`;
+ tr.innerHTML = idCell +
+ `
+
`;
+ tb.appendChild(tr);
+ });
+}
+
+// Kept for saved packages written before the picker existed: their rows have no
+// `source`, so they would render as read-only catalog rows with no way to fix a
+// typo. Anything that didn't come from the catalog is treated as manual.
+function normaliseAsset(a){
+ const o = Object.assign({ tag:'', desc:'', link:'', source:'manual' }, a||{});
+ if(o.source !== 'catalog') o.source = 'manual';
+ return o;
+}
+
+function addManualAsset(){
+ pkgAssets.push(normaliseAsset({}));
+ buildAssets();
+ track('asset_added',{source:'manual'});
+}
+
+// ── CSV IMPORT ───────────────────────────────────────────────────────────────
+// Bulk-add a list of asset ids. Each imported id is checked against the loaded
+// Micron DB: a hit is added as a catalog row (badge, id locked, stored with the
+// DB's own casing); a miss is added as a manual row so it is visibly NOT vouched
+// for rather than silently dropped. Nothing is ever written back to Micron.
+const ASSET_ID_HEADERS = ['asset id','assetid','asset_id','asset','asset tag','assettag','tag','id'];
+
+function importAssets(ev){
+ const f = ev.target.files && ev.target.files[0];
+ if(!f){ return; }
+ const clear = () => { ev.target.value = ''; };
+ if(/\.xlsx?$/i.test(f.name)){
+ alert('Please save the workbook as CSV first (File → Save As → CSV), then load it here.');
+ clear(); return;
+ }
+ const r = new FileReader();
+ r.onload = () => {
+ let rows;
+ try { rows = parseCSV(r.result); }
+ catch(e){ alert('Could not parse that CSV.'); clear(); return; }
+ if(!rows.length){ alert('That file has no rows.'); clear(); return; }
+ applyImportedAssets(rows);
+ clear();
+ };
+ r.onerror = () => { alert('Could not read that file.'); clear(); };
+ r.readAsText(f);
+}
+
+// Which column holds the ids, and whether row 0 is a header.
+// - a recognised header name wins outright;
+// - otherwise pick the column with the most Micron DB hits, so an export with
+// the ids in column D works without the user rearranging it;
+// - failing both (nothing matches — e.g. the DB is offline), use column 0.
+function pickAssetColumn(rows){
+ const head = (rows[0] || []).map(c => String(c || '').trim().toLowerCase());
+ const named = head.findIndex(h => ASSET_ID_HEADERS.includes(h));
+ if(named >= 0) return { col: named, start: 1 };
+
+ const width = rows.slice(0, 200).reduce((w, r) => Math.max(w, r.length), 1);
+ let best = 0, bestHits = 0;
+ for(let c = 0; c < width; c++){
+ let hits = 0;
+ for(let i = 0; i < Math.min(rows.length, 200); i++){
+ const v = String((rows[i] || [])[c] || '').trim();
+ if(v && assetCatalogIndex.has(v.toLowerCase())) hits++;
+ }
+ if(hits > bestHits){ bestHits = hits; best = c; }
+ }
+ return { col: best, start: 0 };
+}
+
+function applyImportedAssets(rows){
+ const { col, start } = pickAssetColumn(rows);
+
+ // Collect, trimmed and de-duplicated within the file itself.
+ const seen = new Set(), ids = [];
+ for(let i = start; i < rows.length; i++){
+ const v = String((rows[i] || [])[col] || '').trim();
+ if(!v) continue;
+ const k = v.toLowerCase();
+ if(seen.has(k)) continue;
+ seen.add(k); ids.push(v);
+ }
+ if(!ids.length){ alert('No asset ids found in that file.'); return; }
+
+ // Cap the import rather than building a table with thousands of rows. Reported,
+ // never silent — a truncated import that looked complete would be worse.
+ const capped = ids.length > ASSET_IMPORT_MAX;
+ const take = capped ? ids.slice(0, ASSET_IMPORT_MAX) : ids;
+
+ let matched = 0, unmatched = 0, dupes = 0;
+ take.forEach(id => {
+ if(assetAlreadyAdded(id)){ dupes++; return; }
+ const canonical = assetCatalogIndex.get(id.toLowerCase());
+ if(canonical){
+ pkgAssets.push({ tag: canonical, desc: '', link: '', source: 'catalog' });
+ matched++;
+ } else {
+ pkgAssets.push({ tag: id, desc: '', link: '', source: 'manual' });
+ unmatched++;
+ }
+ });
+
+ buildAssets();
+ renderAssetResults(); // rows just added should now read "added"
+ track('asset_imported', { matched: matched, unmatched: unmatched });
+
+ // Every id matched, nothing skipped, nothing truncated: a toast is enough.
+ // Anything the user needs to act on — unmatched ids, a silent-looking
+ // truncation, an unchecked import — interrupts with the detail instead.
+ const offline = assetCatalogState !== 'ready';
+ if(matched && !unmatched && !dupes && !capped && !offline){
+ toast('Added ' + matched + ' asset' + (matched === 1 ? '' : 's') + ' from the Micron DB');
+ return;
+ }
+
+ const parts = [];
+ if(matched) parts.push(matched + ' found in the Micron DB');
+ if(unmatched) parts.push(unmatched + ' not in the Micron DB (added as manual rows)');
+ if(dupes) parts.push(dupes + ' already on this package (skipped)');
+ let msg = 'Imported ' + (matched + unmatched) + ' asset' + ((matched + unmatched) === 1 ? '' : 's') +
+ (parts.length ? ':\n\n• ' + parts.join('\n• ') : '');
+ if(capped) msg += '\n\nThe list held ' + ids.length.toLocaleString() + ' ids — only the first ' +
+ ASSET_IMPORT_MAX.toLocaleString() + ' were added.';
+ if(offline) msg += '\n\nNote: the Micron DB was not loaded, so nothing could be ' +
+ 'checked against it — every row was added as manual.';
+ alert(msg);
+}
+
+function removeAsset(i){
+ pkgAssets.splice(i,1);
+ buildAssets();
+ renderAssetResults(); // a removed asset becomes addable again
+}
+
+// ── Catalog lookup ───────────────────────────────────────────────────────────
+function assetSourceNote(msg, tone){
+ const el = document.getElementById('asset-source-note'); if(!el) return;
+ el.textContent = msg || '';
+ el.style.color = tone === 'warn' ? 'var(--red)' : '';
+}
+
+function initAssetPicker(){
+ const box = document.getElementById('asset-search'); if(!box) return;
+ box.addEventListener('input', () => runAssetSearch(box.value));
+ // Re-open on focus only when the list is actually closed. Adding an asset
+ // returns focus to this box, and re-running the search there would rebuild the
+ // list under the cursor and throw away the scroll position mid-multi-add.
+ box.addEventListener('focus', () => {
+ const results = document.getElementById('asset-results');
+ if(results && results.hidden && box.value.trim()) runAssetSearch(box.value);
+ });
+ // Pasting a column of ids straight out of Excel adds them all, rather than
+ // dropping a multi-line blob into a search box that can only match one thing.
+ // Excel gives \r\n between rows and \t between columns — i.e. exactly the CSV
+ // importer's row/cell shape, so it goes through the same matching path.
+ // A single value is left alone: that is an ordinary search, not a bulk add.
+ box.addEventListener('paste', e => {
+ const cb = e.clipboardData || window.clipboardData;
+ const text = cb ? cb.getData('text') : '';
+ if(!text) return;
+ const lines = text.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim());
+ if(lines.length < 2) return; // one id — paste it and search as normal
+ e.preventDefault();
+ applyImportedAssets(lines.map(l => l.split('\t')));
+ box.value = '';
+ assetResults = [];
+ openAssetResults(false);
+ });
+ box.addEventListener('keydown', e => {
+ if(e.key === 'Escape'){ openAssetResults(false); box.blur(); }
+ // Enter adds the first result that isn't already on the package — the common
+ // case of typing an exact tag and taking it without reaching for the mouse.
+ if(e.key === 'Enter'){
+ e.preventDefault();
+ const ix = assetResults.findIndex(tag => !assetAlreadyAdded(tag));
+ if(ix >= 0) addCatalogAsset(ix);
+ }
+ });
+ // Click-away closes, matching the .pp-menu pickers elsewhere on this form.
+ // Tested against composedPath() rather than e.target: adding an asset can
+ // re-render the row that was clicked, and a detached target reports itself as
+ // outside every container, which would close the list on every add.
+ document.addEventListener('click', e => {
+ const path = typeof e.composedPath === 'function' ? e.composedPath() : null;
+ const inside = path && path.length
+ ? path.some(n => n && n.id === 'asset-pick')
+ : !!(e.target.closest && e.target.closest('#asset-pick'));
+ if(!inside) openAssetResults(false);
+ });
+ // Delegated so result rows never need an inline handler carrying catalog text.
+ const results = document.getElementById('asset-results');
+ if(results) results.addEventListener('click', e => {
+ const row = e.target.closest('[data-asset-ix]'); if(!row) return;
+ addCatalogAsset(parseInt(row.getAttribute('data-asset-ix'), 10));
+ });
+
+ box.disabled = true;
+ box.placeholder = 'Loading asset IDs from the Micron DB…';
+ assetSourceNote('Loading asset IDs from the Micron DB…');
+
+ fetch('/api/assets', { headers:{ 'Accept':'application/json' } })
+ .then(r => r.ok ? r.json()
+ : r.json().catch(() => ({})).then(b => Promise.reject(b.detail || 'The Micron DB could not be read.')))
+ .then(body => {
+ if(!body.configured){
+ assetCatalogState = 'absent';
+ box.placeholder = 'Micron DB not configured — add assets manually below';
+ assetSourceNote('The Micron DB is not connected, so assets are entered by hand. Use “+ Add asset not in the Micron DB”.');
+ return;
+ }
+ assetCatalog = (body.assets || []).map(a => String(a.tag || ''));
+ // Lowercased lookup for the CSV importer: it decides whether an imported id
+ // is a real Micron asset, and maps it back to the DB's own casing so an
+ // id typed as "ahu-2p-014" is stored exactly as Micron spells it.
+ assetCatalogIndex = new Map(assetCatalog.map(t => [t.toLowerCase(), t]));
+ assetCatalogState = 'ready';
+ box.disabled = false;
+ box.placeholder = 'Search asset IDs, or paste a column from Excel…';
+ assetSourceNote(assetCatalog.length.toLocaleString() + ' asset IDs loaded from the Micron DB (read-only).');
+ })
+ .catch(err => {
+ assetCatalogState = 'error';
+ box.placeholder = 'Micron DB unavailable — add assets manually below';
+ assetSourceNote(typeof err === 'string' ? err + ' You can still add assets manually.'
+ : 'The Micron DB could not be reached. You can still add assets manually.', 'warn');
+ });
+}
+
+// Filters the loaded catalog in memory. Exact match, then prefix, then contains —
+// so typing a full asset ID puts that asset first rather than whichever ID
+// happens to sort first.
+function runAssetSearch(q){
+ q = String(q||'').trim().toLowerCase();
+ if(!q || assetCatalogState !== 'ready'){
+ assetResults = []; renderAssetResults(); openAssetResults(false); return;
+ }
+ const exact=[], prefix=[], other=[];
+ for(const tag of assetCatalog){
+ const t = tag.toLowerCase();
+ if(t === q) exact.push(tag);
+ else if(t.startsWith(q)) prefix.push(tag);
+ else if(t.includes(q)) other.push(tag);
+ if(exact.length + prefix.length + other.length >= ASSET_RESULT_MAX) break;
+ }
+ assetResults = exact.concat(prefix, other).slice(0, ASSET_RESULT_MAX);
+ renderAssetResults();
+ openAssetResults(true);
+}
+
+function renderAssetResults(){
+ const box = document.getElementById('asset-results'); if(!box) return;
+ if(!assetResults.length){
+ box.innerHTML = `
No matching asset IDs. Add it manually if it isn’t in the Micron DB yet.
`;
+ return;
+ }
+ box.innerHTML = assetResults.map((tag,ix) => {
+ const on = assetAlreadyAdded(tag);
+ return ``;
+ }).join('');
+}
+
+function openAssetResults(open){
+ const box = document.getElementById('asset-results');
+ const inp = document.getElementById('asset-search');
+ if(box) box.hidden = !open;
+ if(inp) inp.setAttribute('aria-expanded', open ? 'true' : 'false');
+}
+
+function addCatalogAsset(ix){
+ const tag = assetResults[ix]; if(!tag) return;
+ if(assetAlreadyAdded(tag)){ toast('That asset is already on this package'); return; }
+ pkgAssets.push({ tag: tag, desc: '', link: '', source: 'catalog' });
+ buildAssets();
+ // Mark just this row instead of re-rendering the list: the results stay open
+ // for the next pick, the scroll position holds, and the clicked element is
+ // never detached mid-click (see the composedPath note in initAssetPicker).
+ markAssetResultAdded(ix);
+ const box = document.getElementById('asset-search');
+ if(box) box.focus(); // keep typing straight into the next search
+ track('asset_added',{source:'catalog'});
+}
+
+function markAssetResultAdded(ix){
+ const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]');
+ if(!row) return;
+ row.classList.add('is-added');
+ row.disabled = true;
+ const label = row.querySelector('.asset-result-add');
+ if(label) label.textContent = 'added';
}
-function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
-function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
function buildAttach(){
@@ -1189,8 +1513,8 @@ function renderPackage(pkg){
${pkg.bimlink?`
Every work package is based on one or more assets managed in controls.dev. Paste the controls.dev link for each asset this package covers. A direct integration to pick assets from a list is planned — for now, link them manually.
-
Asset Tag / ID
Description
controls.dev Link *
-
+
Assetsi
+
Every work package is based on one or more assets from the Micron DB. Search by asset ID, or paste a column of IDs straight from Excel, to add each asset this package covers. The Micron DB is read-only — nothing you do here changes it.
+
+
+
+
+
+
Asset ID
Note (what this asset is / why it's in scope)
+
+
+
+
+
diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css
index a976737..dcd11a2 100644
--- a/html/wp-creation-styles.css
+++ b/html/wp-creation-styles.css
@@ -645,6 +645,38 @@
border:1px solid var(--border); border-radius:3px; }
.pp-free .field-hint { margin-top:4px; }
+ /* ── asset picker (Micron asset catalog) ────────────────────────────────────
+ A search box over a read-only catalog. Results drop below the input and are
+ added to the table as rows; the catalog itself is never written to. */
+ .asset-pick { position:relative; margin-bottom:10px; }
+ .asset-search { width:100%; padding:8px 10px; font:inherit; font-size:13px;
+ border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
+ box-sizing:border-box; }
+ .asset-search:focus { outline:2px solid var(--accent); outline-offset:-2px; }
+ .asset-search:disabled { background:var(--surface2); color:var(--text-dim); cursor:not-allowed; }
+ .asset-results { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:60;
+ max-height:320px; overflow-y:auto; background:var(--surface);
+ border:1px solid var(--border-strong); border-radius:4px; padding:4px 0;
+ box-shadow:0 8px 24px rgba(20,30,50,.18); }
+ .asset-results[hidden] { display:none; }
+ .asset-result { display:flex; align-items:baseline; justify-content:space-between; gap:10px;
+ width:100%; text-align:left; background:none; border:0;
+ font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; color:var(--text); }
+ .asset-result:hover:not(:disabled) { background:var(--surface2); }
+ .asset-result:disabled { cursor:default; opacity:.55; }
+ .asset-result-tag { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .asset-result-add { color:var(--accent); font-size:11.5px; font-weight:700; white-space:nowrap; }
+ .asset-result.is-added .asset-result-add { color:var(--text-dim); font-weight:400; }
+ .asset-result-note { padding:9px 12px; font-size:12.5px; color:var(--text-muted); }
+ /* Marks rows the catalog vouches for, so a manually typed asset is never
+ mistaken for a looked-up one. */
+ .asset-badge { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px;
+ font-size:10px; font-weight:700; letter-spacing:.02em; text-transform:uppercase;
+ color:var(--accent); background:var(--accent-dim); vertical-align:middle;
+ white-space:nowrap; } /* two words now — must not wrap under the asset ID */
+ .asset-tag { font-weight:600; }
+ .asset-empty { color:var(--text-dim); font-size:12.5px; font-style:italic; }
+
/* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
diff --git a/server/.env.example b/server/.env.example
index fa15079..6087901 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -30,3 +30,25 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# notifications are marked "skipped", nothing is sent) until both the toggle is
# on and SMTP is configured.
# SMTP_PASSWORD=your-smtp-app-password
+
+# ── Micron asset catalog (optional) ───────────────────────────────────────────
+# Backs the searchable asset picker in the work package creator. READ-ONLY: the
+# app only ever runs the single SELECT in server/assets_db.py, so give it a
+# db_datareader login and nothing more.
+#
+# Leave this unset and the suite works normally — the picker reports that no
+# catalog is configured and people type asset tags in by hand.
+#
+# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …).
+# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB
+#
+# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt
+# and install the Microsoft ODBC driver in the image:
+# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
+#
+# Two things to check when the picker says the catalog is unreachable:
+# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real
+# Micron schema — that one constant is the whole schema contract.
+# 2. The api container is on the `outbound` network in docker-compose.yml. The
+# `internal` network has no default gateway, which blocks the VPN as well as
+# the internet.
diff --git a/server/app.py b/server/app.py
index d904e7d..77e9fd7 100644
--- a/server/app.py
+++ b/server/app.py
@@ -25,7 +25,7 @@ from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session
from .db import Base, engine, get_db
-from . import models, auth, notify
+from . import models, auth, notify, assets_db
# Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -1810,6 +1810,29 @@ def list_comments(
return [c.to_dict() for c in rows]
+# ── Micron asset catalog (read-only lookup) ────────────────────────────────────
+# Backs the asset picker in the work package creator. This is a *lookup*, not a
+# resource this app owns: there is no POST, and nothing here ever writes to the
+# Micron database. It is deliberately not project-scoped by the app's own access
+# rules — the catalog is reference data, and any signed-in user who can build a
+# work package needs to be able to name the assets it covers. Authentication is
+# still required (the auth_gate middleware covers every /api/ path).
+@app.get("/api/assets")
+def list_assets(_user: models.User = Depends(auth.get_current_user)):
+ """The whole catalog, fetched once when the creator loads. Searching happens
+ in the browser — there is no per-keystroke endpoint by design."""
+ if not assets_db.configured():
+ # Not an error — the suite is designed to run without Micron wired up.
+ # The picker reads this and switches to manual entry.
+ return {"configured": False, "assets": [], "detail": assets_db.status()["detail"]}
+ try:
+ return {"configured": True, "assets": assets_db.load()}
+ except assets_db.AssetSourceError as exc:
+ # 503, not 500: the suite is healthy, its upstream lookup is not. The
+ # picker degrades to manual entry rather than blocking the package.
+ raise HTTPException(status_code=503, detail=str(exc))
+
+
# ── Local dev convenience: serve the static site from this app ──────────────────
# In production NGINX serves html/ and only proxies /api/ here, so this app never
# receives "/" requests, and the api Docker image doesn't even include html/ — so
diff --git a/server/assets_db.py b/server/assets_db.py
new file mode 100644
index 0000000..ca4266c
--- /dev/null
+++ b/server/assets_db.py
@@ -0,0 +1,199 @@
+"""Read-only reader for the Micron asset catalog.
+
+The work package creator used to ask people to paste a controls.dev link for
+every asset. Assets actually live in the Micron database — a SQL Server instance
+that is NOT part of this repo and whose schema is not managed here. This module
+gives the API a *read-only* window onto it so the creator can offer a searchable
+picker instead of free-text links.
+
+How it works: the whole catalog is fetched in one query and handed to the browser
+when the creator loads. Searching then happens in the browser with no round trip
+at all. The catalog is a list of asset IDs — about 9k of them today and not
+expected past 100k — so it is small enough to send whole, and it is slow-moving
+reference data, so there is nothing to gain from querying it per keystroke and a
+lot of latency to lose. A short server-side cache keeps a room full of people
+opening the page from turning into a query each.
+
+Other deliberate constraints:
+
+ * **Read-only, always.** The only statement in this file is the SELECT below.
+ Point it at a login with `db_datareader` and nothing else.
+ * **No prime_db dependency.** A plain SQLAlchemy connection built from a
+ connection string, kept separate from the app's own engine in `db.py`, so a
+ Micron outage can never affect the suite's own database.
+
+Unconfigured is a first-class state: with no `MICRON_DB_URL` set, `configured()`
+returns False, the API says so, and the UI falls back to manual entry. The suite
+boots and runs fine without the Micron database being reachable.
+"""
+import os
+import time
+import logging
+import threading
+
+from sqlalchemy import create_engine, text
+from sqlalchemy.exc import SQLAlchemyError
+
+log = logging.getLogger(__name__)
+
+try:
+ from dotenv import load_dotenv
+ load_dotenv()
+except Exception:
+ pass
+
+
+# ── The query ─────────────────────────────────────────────────────────────────
+# The only place the Micron schema appears; everything else here is plumbing.
+# Returns one row per asset, aliased `tag`. No row cap: the catalog is small
+# enough to hand over whole, and a partial list would silently hide assets.
+#
+# Add a WHERE clause here if some rows should never be offered at all
+# (decommissioned assets, other sites, …). Filtering at the source keeps the
+# payload small, which matters more than anything else here.
+ASSET_QUERY = """
+ SELECT a.AssetID AS tag
+ FROM Asset.Asset AS a
+ ORDER BY a.AssetID
+"""
+
+# How long a fetched catalog is reused before the next page load re-queries.
+CACHE_SECONDS = int(os.getenv("MICRON_ASSETS_CACHE_SECONDS", "300")) # 5 min
+CONNECT_TIMEOUT = 8
+
+
+class AssetSourceError(RuntimeError):
+ """The catalog is configured but could not be read."""
+
+
+# ── Engine (lazy, process-wide) ───────────────────────────────────────────────
+# A full SQLAlchemy URL, e.g.
+# mssql+pymssql://user:pass@host:1433/MicronDB
+# mssql+pyodbc://user:pass@host/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
+# URL-encode any special characters in the password.
+_engine = None
+_engine_lock = threading.Lock()
+
+
+def _db_url() -> str:
+ return os.getenv("MICRON_DB_URL", "").strip()
+
+
+def configured() -> bool:
+ return bool(_db_url())
+
+
+def _validate_url(url: str) -> None:
+ """Catch the one URL mistake that produces a baffling error message.
+
+ A password containing an unencoded '@' makes the URL ambiguous: the parser
+ splits on the first '@', so part of the password ends up parsed as the host.
+ The driver then reports a connection failure against a nonsense hostname that
+ happens to contain a fragment of the password — confusing to read and unsafe
+ to display. Detect it here and say plainly what is wrong.
+
+ Nothing from the URL is included in the message; it never leaves this process.
+ """
+ authority = url.split("://", 1)[-1].split("/", 1)[0]
+ if authority.count("@") > 1:
+ raise AssetSourceError(
+ "MICRON_DB_URL is ambiguous: the username or password contains an "
+ "unencoded '@'. Percent-encode the special characters — @ = %40, "
+ ": = %3A, / = %2F, # = %23, ? = %3F, % = %25."
+ )
+
+
+def _connect_args(url: str) -> dict:
+ """Per-driver connect timeouts, so an unreachable Micron host fails fast
+ instead of tying up a worker until the OS gives up."""
+ if url.startswith("mssql+pymssql"):
+ return {"login_timeout": CONNECT_TIMEOUT, "timeout": CONNECT_TIMEOUT}
+ if url.startswith("mssql+pyodbc"):
+ return {"timeout": CONNECT_TIMEOUT}
+ return {}
+
+
+def _get_engine():
+ global _engine
+ if _engine is not None:
+ return _engine
+ url = _db_url()
+ if not url:
+ raise AssetSourceError("The Micron DB is not configured.")
+ _validate_url(url)
+ with _engine_lock:
+ if _engine is None:
+ try:
+ _engine = create_engine(
+ url,
+ connect_args=_connect_args(url),
+ pool_pre_ping=True, # a recycled dead connection retries instead of erroring
+ pool_recycle=1800,
+ pool_size=1, # one catalog query now and then, not a workload
+ max_overflow=1,
+ future=True,
+ )
+ except Exception as exc: # bad URL, missing driver package, …
+ # See the note on load() — the exception text can echo the
+ # connection string, so it is logged and not propagated.
+ log.error("Micron asset catalog: could not open the connection: %s", exc)
+ raise AssetSourceError(
+ "Could not open a connection to the Micron DB. "
+ "Check MICRON_DB_URL and the API log for the driver error."
+ ) from exc
+ return _engine
+
+
+# ── Cache ─────────────────────────────────────────────────────────────────────
+# Every page load asks for the whole catalog, so without this a shift change
+# would be one full-table query per person. Held per worker process.
+_cache: list[dict] | None = None
+_cached_at = 0.0
+_cache_lock = threading.Lock()
+
+
+def load(force: bool = False) -> list[dict]:
+ """Return the whole catalog as [{'tag': …}, …]. Never writes."""
+ global _cache, _cached_at
+ with _cache_lock:
+ if _cache is not None and not force and (time.monotonic() - _cached_at) < CACHE_SECONDS:
+ return _cache
+
+ engine = _get_engine()
+ try:
+ with engine.connect() as conn:
+ result = conn.execute(text(ASSET_QUERY)).mappings().all()
+ except SQLAlchemyError as exc:
+ # The driver's message is NOT propagated. AssetSourceError text reaches the
+ # browser, and connection errors quote the host, the login, and — when the
+ # URL is malformed — fragments of the password. Operators get the detail
+ # from the API log, where it belongs; users get a message they can act on.
+ log.error("Micron asset catalog query failed: %s", exc)
+ raise AssetSourceError(
+ "The Micron DB could not be read. Check that the host is "
+ "reachable, that the login has SELECT on the asset table, and that "
+ "ASSET_QUERY matches the real schema — the API log has the driver error."
+ ) from exc
+
+ # Drop rows with no identifier — an asset with no tag is not selectable and
+ # would render as a blank line in the picker.
+ rows = [{"tag": str(r["tag"])} for r in result if r.get("tag") not in (None, "")]
+ with _cache_lock:
+ _cache, _cached_at = rows, time.monotonic()
+ return rows
+
+
+def status() -> dict:
+ """Describe the source for the UI, so it can explain itself rather than just
+ showing an empty dropdown."""
+ if not configured():
+ return {
+ "configured": False, "ok": False, "count": 0,
+ "detail": "The Micron DB is not configured — enter assets manually.",
+ }
+ try:
+ rows = load()
+ except AssetSourceError as exc:
+ return {"configured": True, "ok": False, "count": 0, "detail": str(exc)}
+ return {"configured": True, "ok": True, "count": len(rows),
+ "detail": f"{len(rows):,} asset IDs from the Micron DB."}
diff --git a/server/requirements.txt b/server/requirements.txt
index 20f9b31..28ac028 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -9,6 +9,12 @@ gunicorn==26.0.0
sqlalchemy==2.0.51
alembic==1.18.5 # database migrations
psycopg[binary]==3.3.4
+pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL Server).
+ # Chosen over pyodbc because it ships self-contained wheels —
+ # pyodbc would also need msodbcsql18 + unixODBC installed in
+ # the image. To use pyodbc instead, add it here, install the
+ # Microsoft ODBC driver in the Dockerfile, and switch
+ # MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
pydantic==2.13.4
python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing