From 690d185dc28b9c9236d68c1ecc154b408750a28b Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 15 May 2026 16:08:04 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20reviewing/=20lifecycle=20=E2=80=94=20Pl?= =?UTF-8?q?an=20Review=20endpoint,=20virtual=20received=20window,=20browse?= =?UTF-8?q?=20context-menu=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layers shipped together since the second builds on the first. LAYER 1 — reviewing/ + Plan Review scaffolding - reviewing/ is now a real folder under each project, populated by the Plan Review composite endpoint. The old reviewing/ virtual aggregator handler is retired. - POST //archive//received// with X-ZDDC-Op: plan-review scaffolds physical workflow folders under reviewing_root and staging_root, each carrying .zddc.received_path pointing back at the canonical submittal. Idempotent re-runs match by received_path and re-converge the ACL. - Virtual received window: when listing or writing under /received/, the server resolves through the canonical archive//received// via the workflow's .zddc.received_path. Writes get rewritten to /+C so review comments land in the workflow folder and never touch the WORM archive. - Cascade defaults declare on_plan_review per project so the reviewing_root and staging_root are configurable. LAYER 2 — browse context-menu workflows - Accept Transmittal: right-click a transmittal folder in archive//incoming/ → validates ZDDC folder + filename conformance, atomic-renames the folder to archive//received// (WORM zone), and optionally chains into Plan Review in the same composite request. Re-acceptance with a different revision merges file-by-file; WORM forbids overwrite of an existing filename. - Stage / Unstage: right-click files in working/<…>/ → "Stage to…" with picker of existing staging transmittal folders + inline "New transmittal folder…" create; right-click files in staging/<…>/ → "Unstage to working/" defaulting to the user's working// home. Reuses the file-API move primitive. - Create Transmittal folder: right-click the staging/ pane → prompts for a ZDDC-conforming folder name with live validation; mkdir, then navigate to the new folder URL where the transmittal tool serves the editor. - Supporting infrastructure: new CanonicalFolderAt cascade lookup + X-ZDDC-Canonical-Folder response header so the browse SPA can scope-gate menu items without re-implementing the cascade client-side. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/build.sh | 4 + browse/js/accept-transmittal.js | 312 ++++++++++++ browse/js/create-transmittal.js | 146 ++++++ browse/js/events.js | 104 ++++ browse/js/loader.js | 14 + browse/js/plan-review.js | 276 +++++++++++ browse/js/stage.js | 329 +++++++++++++ browse/js/upload.js | 66 +++ shared/zddc.js | 1 + zddc/cmd/zddc-server/main.go | 53 +-- zddc/internal/fs/tree.go | 36 ++ zddc/internal/handler/accepthandler.go | 274 +++++++++++ zddc/internal/handler/accepthandler_test.go | 193 ++++++++ zddc/internal/handler/directory.go | 16 + zddc/internal/handler/fileapi.go | 47 ++ zddc/internal/handler/planreview.go | 449 ++++++++++++++++++ zddc/internal/handler/planreview_test.go | 321 +++++++++++++ zddc/internal/handler/reviewinghandler.go | 424 ----------------- .../internal/handler/reviewinghandler_test.go | 220 --------- zddc/internal/zddc/defaults.zddc.yaml | 27 +- zddc/internal/zddc/file.go | 51 ++ zddc/internal/zddc/lookups.go | 58 +++ zddc/internal/zddc/lookups_test.go | 43 +- zddc/internal/zddc/virtualreceived.go | 222 +++++++++ zddc/internal/zddc/walker.go | 12 + 25 files changed, 3016 insertions(+), 682 deletions(-) create mode 100644 browse/js/accept-transmittal.js create mode 100644 browse/js/create-transmittal.js create mode 100644 browse/js/plan-review.js create mode 100644 browse/js/stage.js create mode 100644 zddc/internal/handler/accepthandler.go create mode 100644 zddc/internal/handler/accepthandler_test.go create mode 100644 zddc/internal/handler/planreview.go create mode 100644 zddc/internal/handler/planreview_test.go delete mode 100644 zddc/internal/handler/reviewinghandler.go delete mode 100644 zddc/internal/handler/reviewinghandler_test.go create mode 100644 zddc/internal/zddc/virtualreceived.go diff --git a/browse/build.sh b/browse/build.sh index cd71584..78c18bb 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -69,6 +69,10 @@ concat_files \ "js/grid.js" \ "js/upload.js" \ "js/download.js" \ + "js/plan-review.js" \ + "js/accept-transmittal.js" \ + "js/stage.js" \ + "js/create-transmittal.js" \ "js/events.js" \ "js/app.js" \ > "$js_raw" diff --git a/browse/js/accept-transmittal.js b/browse/js/accept-transmittal.js new file mode 100644 index 0000000..ad6718c --- /dev/null +++ b/browse/js/accept-transmittal.js @@ -0,0 +1,312 @@ +// accept-transmittal.js — the doc-controller "Accept Transmittal" +// workflow modal. +// +// Surfaced by events.js as a right-click item on a transmittal folder +// inside archive//incoming/. The folder name must conform +// to the ZDDC transmittal grammar (date_tracking (status) - title); +// every file inside must conform to ZDDC filename grammar with the +// same tracking. Non-conformance is flagged in the modal and the user +// cancels to ask the sender to fix. +// +// On submit, the form assembles a YAML body (received_date plus an +// optional plan-review chain block) and POSTs it with +// X-ZDDC-Op: accept-transmittal to the transmittal-folder URL. The +// server validates everything, moves the folder into received/, +// renames it to tracking-only, and optionally chains Plan Review. + +(function () { + 'use strict'; + + var REVIEW_OFFSET_DAYS = 7; + var RESPONSE_OFFSET_DAYS = 14; + + function status(msg, level) { + var t = window.zddc && window.zddc.toast; + if (t) t(msg, level || 'info'); + } + + function isoDateToday() { + var d = new Date(); + return d.getFullYear() + + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + + '-' + ('0' + d.getDate()).slice(-2); + } + function isoDatePlus(days) { + var d = new Date(); + d.setDate(d.getDate() + days); + return d.getFullYear() + + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + + '-' + ('0' + d.getDate()).slice(-2); + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return ({ + '&': '&', '<': '<', '>': '>', + '"': '"', "'": ''' + })[c]; + }); + } + + // Is this node a direct child of an incoming/ canonical folder + // AND a well-formed transmittal folder? The first half is the + // cascade-driven scope check (X-ZDDC-Canonical-Folder == 'incoming' + // on the current listing's parent context); the second is a + // structural folder-name parse against the ZDDC grammar. + function isAcceptableTransmittalFolder(node) { + if (!node || !node.isDir) return false; + if (node.virtual) return false; + // The cascade signal is on the PARENT directory's listing, which + // is the directory whose contents are currently shown — i.e. + // state.currentPath. When the listing's scope is incoming/, + // every direct child folder is a candidate (validated by name + // here and by the server again on POST). + if (window.app.state.scopeCanonicalFolder !== 'incoming') return false; + var parsed = window.zddc.parseFolder(node.name); + return !!(parsed && parsed.valid); + } + + // Scan the listing's tree node for files inside the transmittal + // folder and classify each as conforming (tracking matches the + // folder) or violating. Returns { ok: [...], violations: [...] }. + // Best-effort — operates only on already-loaded children. The + // server is authoritative; this is a UX hint. + function classifyChildren(node, folderTracking) { + var out = { ok: [], violations: [] }; + var children = (node && node.children) ? node.children : []; + children.forEach(function (c) { + if (c.virtual) return; + if (c.isDir) { + out.violations.push(c.name + ': nested directories are not permitted'); + return; + } + if (c.name.charAt(0) === '.') return; // dotfiles ignored + var parsed = window.zddc.parseFilename(c.name); + if (!parsed || !parsed.valid) { + out.violations.push(c.name + ': does not conform to ZDDC filename grammar'); + return; + } + if (parsed.trackingNumber !== folderTracking) { + out.violations.push(c.name + ': tracking "' + parsed.trackingNumber + + '" does not match folder tracking "' + folderTracking + '"'); + return; + } + out.ok.push(c.name); + }); + return out; + } + + function fetchPeopleSuggestions() { + return fetch('/.profile/access', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(function (r) { + if (!r.ok) return []; + return r.json().then(function (data) { + var out = []; + if (data && data.email) out.push(data.email); + return out; + }); + }).catch(function () { return []; }); + } + + function openForm(initial) { + return new Promise(function (resolve, reject) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;'; + + var violationsHtml = ''; + if (initial.violations && initial.violations.length) { + violationsHtml = '
' + + 'Non-conforming files detected:
    ' + + initial.violations.map(function (v) { return '
  • ' + escapeHtml(v) + '
  • '; }).join('') + + '

Cancel and contact the sender to correct these before re-uploading.

'; + } + + var planReviewFieldsHtml = + ''; + + box.innerHTML = + '

Accept Transmittal — ' + escapeHtml(initial.tracking) + '

' + + '

' + + 'This will file ' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' from ' + + '' + escapeHtml(initial.folder) + ' into the immutable received archive at ' + + 'archive/' + escapeHtml(initial.party) + '/received/' + escapeHtml(initial.tracking) + '/. ' + + 'Once filed, only document-control can add new files there; nothing can be edited or deleted.' + + '

' + + violationsHtml + + '
' + + '' + + '' + + '
' + + '' + + planReviewFieldsHtml + + '
' + + '' + + '' + + '
'; + + overlay.appendChild(box); + document.body.appendChild(overlay); + + box.querySelector('#acc-received-date').value = isoDateToday(); + box.querySelector('#acc-review-date').value = isoDatePlus(REVIEW_OFFSET_DAYS); + box.querySelector('#acc-response-date').value = isoDatePlus(RESPONSE_OFFSET_DAYS); + + var prCheckbox = box.querySelector('#acc-setup-pr'); + var prFields = box.querySelector('#acc-pr-fields'); + prCheckbox.addEventListener('change', function () { + prFields.style.display = prCheckbox.checked ? '' : 'none'; + }); + + fetchPeopleSuggestions().then(function (emails) { + var dl = box.querySelector('#acc-people'); + if (!dl) return; + emails.forEach(function (e) { + var opt = document.createElement('option'); + opt.value = e; + dl.appendChild(opt); + }); + }); + + function close() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + box.querySelector('#acc-cancel').addEventListener('click', function () { + close(); reject(new Error('cancelled')); + }); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { close(); reject(new Error('cancelled')); } + }); + document.addEventListener('keydown', function escHandler(e) { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + close(); reject(new Error('cancelled')); + } + }); + + box.querySelector('#acc-submit').addEventListener('click', function () { + var values = { + receivedDate: box.querySelector('#acc-received-date').value, + setupPlanReview: prCheckbox.checked, + reviewLead: box.querySelector('#acc-review-lead').value.trim(), + approver: box.querySelector('#acc-approver').value.trim(), + planReviewDate: box.querySelector('#acc-review-date').value, + planResponseDate: box.querySelector('#acc-response-date').value + }; + if (!values.receivedDate) { status('Received date is required.', 'error'); return; } + if (values.setupPlanReview) { + if (!values.reviewLead || !values.approver + || !values.planReviewDate || !values.planResponseDate) { + status('Plan Review fields are required when the checkbox is on.', 'error'); + return; + } + } + close(); resolve(values); + }); + }); + } + + function quote(s) { + return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; + } + function buildBody(values) { + var lines = ['received_date: ' + values.receivedDate]; + if (values.setupPlanReview) { + lines.push('setup_plan_review: true'); + lines.push('review_lead: ' + quote(values.reviewLead)); + lines.push('approver: ' + quote(values.approver)); + lines.push('plan_review_complete_date: ' + values.planReviewDate); + lines.push('plan_response_date: ' + values.planResponseDate); + } + lines.push(''); + return lines.join('\n'); + } + + async function invoke(node) { + var tree = window.app.modules.tree; + if (!tree) return; + var url = tree.pathFor(node); + if (!url.endsWith('/')) url += '/'; + + var parsedFolder = window.zddc.parseFolder(node.name); + if (!parsedFolder || !parsedFolder.valid) { + status('Folder name does not conform to ZDDC transmittal grammar.', 'error'); + return; + } + + // Derive the party from the path: archive//incoming//. + var parts = url.replace(/^\/+|\/+$/g, '').split('/'); + var partyIdx = parts.indexOf('archive'); + var party = (partyIdx >= 0 && parts[partyIdx + 1]) ? parts[partyIdx + 1] : ''; + + var classification = classifyChildren(node, parsedFolder.trackingNumber); + + var values; + try { + values = await openForm({ + tracking: parsedFolder.trackingNumber, + folder: node.name, + party: party, + fileCount: classification.ok.length, + violations: classification.violations + }); + } catch (_e) { + return; + } + + status('Accept Transmittal — submitting…'); + var resp; + try { + resp = await fetch(url, { + method: 'POST', + headers: { + 'X-ZDDC-Op': 'accept-transmittal', + 'Content-Type': 'application/yaml' + }, + body: buildBody(values), + credentials: 'same-origin' + }); + } catch (e) { + status('Accept failed: ' + (e && e.message ? e.message : e), 'error'); + return; + } + if (!resp.ok) { + var text = ''; + try { text = await resp.text(); } catch (_e) { /* ignore */ } + status('Accept failed (' + resp.status + '): ' + text, 'error'); + return; + } + var data; try { data = await resp.json(); } catch (_e) { data = null; } + var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into ' + + (data && data.received_path ? data.received_path : 'received/'); + if (data && data.merged) msg += ' (merged with existing tracking)'; + if (data && data.plan_review) msg += ' · Plan Review scaffolded'; + status(msg + ' — reload to see the move.', 'success'); + } + + window.app.modules.acceptTransmittal = { + isAcceptableTransmittalFolder: isAcceptableTransmittalFolder, + invoke: invoke + }; +})(); diff --git a/browse/js/create-transmittal.js b/browse/js/create-transmittal.js new file mode 100644 index 0000000..40bfc53 --- /dev/null +++ b/browse/js/create-transmittal.js @@ -0,0 +1,146 @@ +// create-transmittal.js — folder-creation plumbing for outgoing +// transmittals. +// +// Surfaced by events.js as a pane-menu item (right-click empty space) +// when state.scopeCanonicalFolder == 'staging'. The modal prompts for +// a ZDDC-conforming folder name (date_tracking (purpose) - subject) +// with live validation via zddc.parseFolder, then POSTs X-ZDDC-Op: +// mkdir. On success the client navigates to the new folder URL — the +// staging/ cascade serves the transmittal tool there, where the user +// builds the manifest, adds files, and publishes. +// +// No manifest assembly happens here. This is plumbing. + +(function () { + 'use strict'; + + function status(msg, level) { + var t = window.zddc && window.zddc.toast; + if (t) t(msg, level || 'info'); + } + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]; + }); + } + function isoDateToday() { + var d = new Date(); + return d.getFullYear() + + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + + '-' + ('0' + d.getDate()).slice(-2); + } + + function openForm() { + return new Promise(function (resolve, reject) { + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);'; + box.innerHTML = + '

Create Transmittal folder

' + + '

' + + "After it's created, the transmittal tool opens here so you can build the manifest — " + + 'add rows from the MDL, choose revisions, and associate files.' + + '

' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
'; + overlay.appendChild(box); + document.body.appendChild(overlay); + + var input = box.querySelector('#ct-name'); + var submit = box.querySelector('#ct-submit'); + var feedback = box.querySelector('#ct-feedback'); + function revalidate() { + var v = input.value.trim(); + if (!v) { + feedback.textContent = ''; + submit.disabled = true; + return; + } + var parsed = window.zddc.parseFolder(v); + if (parsed && parsed.valid) { + feedback.style.color = '#2a8'; + feedback.textContent = '✓ tracking=' + parsed.trackingNumber + + ', status=' + parsed.status + ', title=' + parsed.title; + submit.disabled = false; + } else { + feedback.style.color = '#c33'; + feedback.textContent = '✗ does not match YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT'; + submit.disabled = true; + } + } + input.addEventListener('input', revalidate); + revalidate(); + + function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } + box.querySelector('#ct-cancel').addEventListener('click', function () { + close(); reject(new Error('cancelled')); + }); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { close(); reject(new Error('cancelled')); } + }); + document.addEventListener('keydown', function escHandler(e) { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + close(); reject(new Error('cancelled')); + } + }); + submit.addEventListener('click', function () { + var v = input.value.trim(); + var parsed = window.zddc.parseFolder(v); + if (!parsed || !parsed.valid) { + status('Folder name must conform to ZDDC convention.', 'error'); + return; + } + close(); resolve({ folderName: v }); + }); + + // Position cursor after the date prefix. + setTimeout(function () { + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + }, 0); + }); + } + + async function invoke() { + if (window.app.state.scopeCanonicalFolder !== 'staging') { + status('Create Transmittal folder is only available inside staging/.', 'error'); + return; + } + var stagingUrl = window.app.state.currentPath || '/'; + if (!stagingUrl.endsWith('/')) stagingUrl += '/'; + + var choice; + try { choice = await openForm(); } catch (_e) { return; } + var newUrl = stagingUrl + encodeURIComponent(choice.folderName) + '/'; + + var resp; + try { + resp = await fetch(newUrl, { + method: 'POST', + headers: { 'X-ZDDC-Op': 'mkdir' }, + credentials: 'same-origin' + }); + } catch (e) { + status('Create failed: ' + (e && e.message ? e.message : e), 'error'); + return; + } + if (!resp.ok) { + var text = ''; try { text = await resp.text(); } catch (_e) {} + status('Create failed (' + resp.status + '): ' + text, 'error'); + return; + } + status('Created ' + choice.folderName + ' — opening transmittal tool…', 'success'); + // Navigate to the new folder (no-slash form → default_tool: transmittal). + window.location.href = stagingUrl + encodeURIComponent(choice.folderName); + } + + window.app.modules.createTransmittal = { invoke: invoke }; +})(); diff --git a/browse/js/events.js b/browse/js/events.js index 302e588..afeb215 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -368,6 +368,21 @@ return p; } + // True when this node is a file viewed through the synthetic + // /received/ window — the URL has a `received/` segment + // that's NOT preceded by `archive//` (the canonical record + // form). A drop here is a review-comment intent: server rewrites to + // /+C. + function isVirtualReceivedFile(node) { + if (!node || node.isDir || state.source !== 'server') return false; + var url = tree.pathFor(node); + var parts = url.replace(/^\/+/, '').split('/'); + var idx = parts.indexOf('received'); + if (idx < 2) return false; + // Canonical form: parts[idx - 2] === 'archive'. Virtual form: anything else. + return parts[idx - 2].toLowerCase() !== 'archive'; + } + function dragHasFiles(e) { if (!e.dataTransfer || !e.dataTransfer.types) return false; var types = e.dataTransfer.types; @@ -422,6 +437,28 @@ var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; + // Comment-upload short-circuit: drop on a file that lives + // under the virtual /received/ window is a "comment + // on this file" intent. PUT to the target's URL — the server + // rewrites to /+C and the canonical + // record (WORM) stays untouched. Confirm first so the user + // sees what's about to happen. + if (!node.isDir && isVirtualReceivedFile(node)) { + e.preventDefault(); + e.stopPropagation(); + if (!window.confirm("Drop bytes here as a review comment on '" + node.name + "'? The server will save it in the workflow folder with a +C revision modifier.")) { + return; + } + var upMod = window.app.modules.upload; + if (!upMod) return; + var targetURL = tree.pathFor(node); + try { + await upMod.uploadCommentToTarget(targetURL, e.dataTransfer); + } catch (err) { + statusError('Comment upload failed: ' + (err.message || err)); + } + return; + } var dest = targetDirForNode(node); if (!dest) return; e.preventDefault(); @@ -776,6 +813,62 @@ }, { separator: true }, + // ── Plan Review (received// only, cascade-gated) ── + { + label: 'Plan Review…', + visible: function (c) { + if (!serverMode) return false; + if (!state.scopeOnPlanReview) return false; + var pr = window.app.modules.planReview; + if (!pr) return false; + return pr.isReceivedTrackingFolder(c.node); + }, + action: function (c) { + var pr = window.app.modules.planReview; + if (pr) pr.invoke(c.node); + } + }, + // ── Accept Transmittal (transmittal folder under incoming/) ── + { + label: 'Accept Transmittal…', + visible: function (c) { + if (!serverMode) return false; + var at = window.app.modules.acceptTransmittal; + if (!at) return false; + return at.isAcceptableTransmittalFolder(c.node); + }, + action: function (c) { + var at = window.app.modules.acceptTransmittal; + if (at) at.invoke(c.node); + } + }, + // ── Stage / Unstage (files under working/ or staging/) ── + { + label: 'Stage to…', + visible: function (c) { + if (!serverMode) return false; + var s = window.app.modules.stage; + return !!(s && s.isStageableFile(c.node)); + }, + action: function (c) { + var s = window.app.modules.stage; + if (s) s.invokeStage(c.node); + } + }, + { + label: 'Unstage to working/', + visible: function (c) { + if (!serverMode) return false; + var s = window.app.modules.stage; + return !!(s && s.isUnstageableFile(c.node)); + }, + action: function (c) { + var s = window.app.modules.stage; + if (s) s.invokeUnstage(c.node); + } + }, + { separator: true }, + // ── View ── { label: 'Sort by', items: SORT_BY_ITEMS }, { label: 'Show hidden files', @@ -803,6 +896,17 @@ disabled: !serverMode, action: function () { createInDir(state.currentPath || '/', 'markdown'); } }, + // ── Create Transmittal folder (staging/ scope only) ── + { + label: 'Create Transmittal folder…', + visible: function () { + return serverMode && state.scopeCanonicalFolder === 'staging'; + }, + action: function () { + var ct = window.app.modules.createTransmittal; + if (ct) ct.invoke(); + } + }, { separator: true }, { label: 'Refresh', diff --git a/browse/js/loader.js b/browse/js/loader.js index 834a6c0..0621aa0 100644 --- a/browse/js/loader.js +++ b/browse/js/loader.js @@ -107,6 +107,20 @@ // without re-implementing the cascade client-side. window.app.state.scopeDefaultTool = (resp.headers.get('X-ZDDC-Default-Tool') || '').toLowerCase(); + // X-ZDDC-On-Plan-Review surfaces whether the cascade above + // this path has an on_plan_review block. Drives visibility of + // the "Plan Review" right-click menu item on received// + // folders. + window.app.state.scopeOnPlanReview = + (resp.headers.get('X-ZDDC-On-Plan-Review') || '').toLowerCase() === 'true'; + // X-ZDDC-Canonical-Folder names the canonical project-layout + // slot this directory occupies — "incoming", "received", + // "working", "staging", etc. Drives scope-aware menu items: + // Accept Transmittal (folders under incoming), Stage/Unstage + // (files under working/staging), Create Transmittal folder + // (right-click in staging). + window.app.state.scopeCanonicalFolder = + (resp.headers.get('X-ZDDC-Canonical-Folder') || '').toLowerCase(); if (resp.status === 404) { return []; } diff --git a/browse/js/plan-review.js b/browse/js/plan-review.js new file mode 100644 index 0000000..e688ced --- /dev/null +++ b/browse/js/plan-review.js @@ -0,0 +1,276 @@ +// plan-review.js — the doc-controller "Plan Review" workflow modal. +// +// Surfaced by events.js as a right-click menu item on +// archive//received// folders when the cascade above +// has an on_plan_review block (X-ZDDC-On-Plan-Review header on the +// listing). +// +// The modal collects four fields: +// +// - review_lead (becomes sub-admin of reviewing/<…>/) +// - plan_review_complete_date (the committed review-done date) +// - approver (becomes sub-admin of staging/<…>/) +// - plan_response_date (the committed response-issue date) +// +// The planned dates are immutable from the sub-admins' perspective — +// they live in the canonical submittal's .zddc +// (received//.zddc) where only the doc controller (via Plan +// Review re-run) can change them. The workflow folders' .zddc files +// carry only the back-link + per-folder ACL. +// +// Title is auto-derived server-side from the first ZDDC-parseable +// file in received//. Forecast dates default to the planned +// dates at scaffolding time; the user renames the workflow folder +// directly to update the forecast later. +// +// On submit, the form assembles a YAML body and POSTs it with +// X-ZDDC-Op: plan-review to the received// URL. + +(function () { + 'use strict'; + + var REVIEW_OFFSET_DAYS = 7; + var RESPONSE_OFFSET_DAYS = 14; + + function statusInfo(msg) { + var el = document.getElementById('statusBar'); + if (!el) return; + el.textContent = msg || ''; + el.classList.remove('status-bar--error'); + el.classList.add('status-bar--info'); + } + function statusError(msg) { + var el = document.getElementById('statusBar'); + if (!el) return; + el.textContent = msg || ''; + el.classList.remove('status-bar--info'); + el.classList.add('status-bar--error'); + } + + // Compute today + N days as a YYYY-MM-DD string. + function isoDatePlus(days) { + var d = new Date(); + d.setDate(d.getDate() + days); + var y = d.getFullYear(); + var m = ('0' + (d.getMonth() + 1)).slice(-2); + var dd = ('0' + d.getDate()).slice(-2); + return y + '-' + m + '-' + dd; + } + + // Fetch suggestion emails from /.profile/access so the originator + // field has a datalist of likely values. Best-effort — silent on + // failure (the field still accepts free text). + async function fetchOriginatorSuggestions() { + try { + var resp = await fetch('/.profile/access', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + if (!resp.ok) return []; + var data = await resp.json(); + var out = []; + // The endpoint exposes the current user + any role members + // visible to them. Pull anything that looks like an email + // for the datalist; the field is otherwise free text. + if (data && data.email) out.push(data.email); + return out; + } catch (_e) { + return []; + } + } + + // Build the YAML body for the plan-review POST. Quoting is minimal + // (just enough for emails with special chars). + function buildBody(values) { + function yamlString(s) { + return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; + } + return [ + 'review_lead: ' + yamlString(values.reviewLead), + 'approver: ' + yamlString(values.approver), + 'plan_review_complete_date: ' + values.planReviewDate, + 'plan_response_date: ' + values.planResponseDate, + '' + ].join('\n'); + } + + // Render the modal. Returns a Promise that resolves on submit + // (with the collected values) or rejects on cancel. + function openForm(initial) { + return new Promise(function (resolve, reject) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:24rem;max-width:32rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;'; + + box.innerHTML = + '

Plan Review — ' + escapeHtml(initial.tracking) + '

' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '

Planned dates seal at first submission — they become part of the canonical record (received//.zddc) and the WORM zone prevents further edits. Subsequent Plan Reviews can swap the review lead or approver without changing the dates.

' + + '
' + + '' + + '' + + '
'; + + overlay.appendChild(box); + document.body.appendChild(overlay); + + var reviewLeadInput = box.querySelector('#pr-review-lead'); + var approverInput = box.querySelector('#pr-approver'); + var reviewDateInput = box.querySelector('#pr-review-date'); + var responseDateInput = box.querySelector('#pr-response-date'); + + reviewDateInput.value = isoDatePlus(REVIEW_OFFSET_DAYS); + responseDateInput.value = isoDatePlus(RESPONSE_OFFSET_DAYS); + + // Populate the datalist with people suggestions (best + // effort — silent on failure). + fetchOriginatorSuggestions().then(function (emails) { + var dl = box.querySelector('#pr-people-list'); + if (!dl) return; + emails.forEach(function (e) { + var opt = document.createElement('option'); + opt.value = e; + dl.appendChild(opt); + }); + }); + + function close() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + + box.querySelector('#pr-cancel').addEventListener('click', function () { + close(); + reject(new Error('cancelled')); + }); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { + close(); + reject(new Error('cancelled')); + } + }); + document.addEventListener('keydown', function escHandler(e) { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + close(); + reject(new Error('cancelled')); + } + }); + + box.querySelector('#pr-submit').addEventListener('click', function () { + var values = { + reviewLead: reviewLeadInput.value.trim(), + approver: approverInput.value.trim(), + planReviewDate: reviewDateInput.value, + planResponseDate: responseDateInput.value + }; + if (!values.reviewLead || !values.approver + || !values.planReviewDate || !values.planResponseDate) { + statusError('All fields are required.'); + return; + } + close(); + resolve(values); + }); + + reviewLeadInput.focus(); + }); + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return ({ + '&': '&', '<': '<', '>': '>', + '"': '"', "'": ''' + })[c]; + }); + } + + // Detect whether a tree node is an archive//received// + // folder. The path is path-shaped, not content-based — tracking-number + // content is not inspected (per design). + function isReceivedTrackingFolder(node) { + if (!node || !node.isDir) return false; + var tree = window.app.modules.tree; + if (!tree) return false; + var p = tree.pathFor(node).replace(/\/$/, ''); + var rel = p.replace(/^\/+/, ''); + var parts = rel.split('/'); + return parts.length === 5 + && parts[1].toLowerCase() === 'archive' + && parts[3].toLowerCase() === 'received'; + } + + // Run the Plan Review flow: open the modal, POST the result. + async function invoke(node) { + var tree = window.app.modules.tree; + if (!tree) return; + var url = tree.pathFor(node); + if (!url.endsWith('/')) url += '/'; + var parts = url.replace(/^\/+/, '').replace(/\/$/, '').split('/'); + var tracking = parts[parts.length - 1]; + + var values; + try { + values = await openForm({ tracking: tracking }); + } catch (_e) { + return; // cancelled + } + + statusInfo('Plan Review — submitting…'); + var body = buildBody(values); + var resp; + try { + resp = await fetch(url, { + method: 'POST', + headers: { + 'X-ZDDC-Op': 'plan-review', + 'Content-Type': 'application/yaml' + }, + body: body, + credentials: 'same-origin' + }); + } catch (e) { + statusError('Plan Review failed: ' + (e && e.message ? e.message : e)); + return; + } + if (!resp.ok) { + var text = ''; + try { text = await resp.text(); } catch (_e) { /* ignore */ } + statusError('Plan Review failed (' + resp.status + '): ' + text); + return; + } + var data; + try { data = await resp.json(); } catch (_e) { data = null; } + if (data && data.reviewing && data.staging) { + var rPart = data.reviewing.created ? 'created' : 'updated'; + var sPart = data.staging.created ? 'created' : 'updated'; + var seal = (data.received && data.received.created) + ? ' Canonical record sealed.' + : (data.received && !data.received.zddc_written) + ? ' Canonical dates left untouched (already sealed).' + : ''; + statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal + + ' Reload the relevant folder to see the new entries.'); + } else { + statusInfo('Plan Review complete.'); + } + } + + window.app.modules.planReview = { + isReceivedTrackingFolder: isReceivedTrackingFolder, + invoke: invoke + }; +})(); diff --git a/browse/js/stage.js b/browse/js/stage.js new file mode 100644 index 0000000..869185d --- /dev/null +++ b/browse/js/stage.js @@ -0,0 +1,329 @@ +// stage.js — Stage and Unstage workflow modals. +// +// Stage: move a file from working/<…>/ into a transmittal folder under +// staging/<…>/. Modal lists existing transmittal folders in staging/ +// plus a "New transmittal folder…" option that prompts for a ZDDC- +// conforming name and mkdirs it before the move. +// +// Unstage: move a file from staging// back to the user's +// working// home (overridable). +// +// Both reuse the existing X-ZDDC-Op: move primitive — no new composite +// endpoint is needed; the client just orchestrates one POST per file +// (a multi-file selection iterates and reports aggregate status). + +(function () { + 'use strict'; + + function status(msg, level) { + var t = window.zddc && window.zddc.toast; + if (t) t(msg, level || 'info'); + } + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]; + }); + } + + // ── Scope detection: path-shape, not cascade-content ────────────── + // A file is stageable if its containing folder lives under + // //working/<…>. Unstageable if it lives under + // //staging//<…>. Both are path-shape + // queries — content/ACL is enforced server-side. + + function projectAndSubtree(path) { + var rel = path.replace(/^\/+|\/+$/g, '').split('/'); + if (rel.length < 2) return null; + return { project: rel[0], subtree: rel[1], rest: rel.slice(2) }; + } + + function isStageableFile(node) { + if (!node || node.isDir || node.virtual) return false; + var tree = window.app.modules.tree; + if (!tree) return false; + var p = projectAndSubtree(tree.pathFor(node)); + return !!(p && p.subtree === 'working' && p.rest.length >= 1); + } + function isUnstageableFile(node) { + if (!node || node.isDir || node.virtual) return false; + var tree = window.app.modules.tree; + if (!tree) return false; + var p = projectAndSubtree(tree.pathFor(node)); + // staging// — at least one folder + // segment between staging/ and the file. + return !!(p && p.subtree === 'staging' && p.rest.length >= 2); + } + + // ── Server helpers ───────────────────────────────────────────────── + + // Fetch directory listing JSON. Returns [] on 404. + async function listDir(absUrl) { + if (!absUrl.endsWith('/')) absUrl += '/'; + var resp = await fetch(absUrl, { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + if (resp.status === 404) return []; + if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + absUrl); + var data = await resp.json(); + return Array.isArray(data) ? data : []; + } + + async function fetchStagingFolders(project) { + var entries = await listDir('/' + project + '/staging/'); + return entries + .filter(function (e) { return e && e.isDir; }) + .map(function (e) { return e.name; }); + } + + async function fetchSelfEmail() { + try { + var r = await fetch('/.profile/access', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + if (!r.ok) return ''; + var d = await r.json(); + return (d && d.email) || ''; + } catch (_e) { return ''; } + } + + // POST X-ZDDC-Op: mkdir to create a new directory. Idempotent. + async function mkdir(absUrl) { + var resp = await fetch(absUrl, { + method: 'POST', + headers: { 'X-ZDDC-Op': 'mkdir' }, + credentials: 'same-origin' + }); + if (!resp.ok) { + var text = ''; try { text = await resp.text(); } catch (_e) {} + throw new Error('mkdir ' + absUrl + ' failed (' + resp.status + '): ' + text); + } + } + + // POST X-ZDDC-Op: move + X-ZDDC-Destination header. Reuses the + // file-API move primitive (atomic os.Rename, dual ACL gates). + async function moveFile(srcUrl, dstUrl) { + var resp = await fetch(srcUrl, { + method: 'POST', + headers: { + 'X-ZDDC-Op': 'move', + 'X-ZDDC-Destination': dstUrl + }, + credentials: 'same-origin' + }); + if (!resp.ok) { + var text = ''; try { text = await resp.text(); } catch (_e) {} + throw new Error('move ' + srcUrl + ' → ' + dstUrl + ' failed (' + resp.status + '): ' + text); + } + } + + // ── Stage picker modal ───────────────────────────────────────────── + + function openStagePicker(initial) { + return new Promise(function (resolve, reject) { + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);'; + + var folderList = initial.folders.map(function (name) { + return ''; + }).join(''); + + box.innerHTML = + '

Stage ' + + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' to…

' + + '

' + + 'Pick the transmittal folder in staging/ these files should join. ' + + 'You can move them back to working/ later if they need correction.' + + '

' + + '
' + + (folderList || 'No existing transmittal folders in staging/.') + + '' + + '
' + + '' + + '
' + + '' + + '' + + '
'; + overlay.appendChild(box); + document.body.appendChild(overlay); + + var newRow = box.querySelector('#stage-newname-row'); + var newInput = box.querySelector('#stage-newname'); + var feedback = box.querySelector('#stage-newname-feedback'); + box.querySelectorAll('input[name="stage-target"]').forEach(function (r) { + r.addEventListener('change', function () { + newRow.style.display = (r.value === '__new__' && r.checked) ? '' : 'none'; + if (r.value === '__new__' && r.checked) newInput.focus(); + }); + }); + newInput.addEventListener('input', function () { + var v = newInput.value.trim(); + if (!v) { feedback.textContent = ''; return; } + var parsed = window.zddc.parseFolder(v); + if (parsed && parsed.valid) { + feedback.style.color = '#2a8'; + feedback.textContent = '✓ tracking=' + parsed.trackingNumber + + ', status=' + parsed.status + ', title=' + parsed.title; + } else { + feedback.style.color = '#c33'; + feedback.textContent = '✗ does not match YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT'; + } + }); + + function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } + box.querySelector('#stage-cancel').addEventListener('click', function () { + close(); reject(new Error('cancelled')); + }); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { close(); reject(new Error('cancelled')); } + }); + box.querySelector('#stage-submit').addEventListener('click', function () { + var sel = box.querySelector('input[name="stage-target"]:checked'); + if (!sel) { status('Pick a destination folder.', 'error'); return; } + if (sel.value === '__new__') { + var name = newInput.value.trim(); + var parsed = window.zddc.parseFolder(name); + if (!parsed || !parsed.valid) { + status('Folder name must conform to ZDDC convention.', 'error'); + return; + } + close(); resolve({ create: true, folderName: name }); + } else { + close(); resolve({ create: false, folderName: sel.value }); + } + }); + }); + } + + // ── Unstage picker modal ─────────────────────────────────────────── + + function openUnstagePicker(initial) { + return new Promise(function (resolve, reject) { + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);'; + box.innerHTML = + '

Unstage ' + + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '

' + + '

' + + 'Move these files back into your drafting workspace under working/ ' + + 'so they can be corrected. Stage them again when ready.' + + '

' + + '' + + '' + + '
' + + '' + + '' + + '
'; + overlay.appendChild(box); + document.body.appendChild(overlay); + + var input = box.querySelector('#unstage-target'); + function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } + box.querySelector('#unstage-cancel').addEventListener('click', function () { + close(); reject(new Error('cancelled')); + }); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { close(); reject(new Error('cancelled')); } + }); + box.querySelector('#unstage-submit').addEventListener('click', function () { + var target = input.value.trim(); + if (!target) { status('Destination is required.', 'error'); return; } + close(); resolve({ target: target }); + }); + }); + } + + // ── Action drivers ───────────────────────────────────────────────── + + async function invokeStage(node) { + var tree = window.app.modules.tree; + if (!tree) return; + var srcUrl = tree.pathFor(node); + var info = projectAndSubtree(srcUrl); + if (!info || info.subtree !== 'working') { + status('Stage applies only to files under working/.', 'error'); + return; + } + var stagingBase = '/' + info.project + '/staging/'; + var folders; + try { folders = await fetchStagingFolders(info.project); } + catch (e) { + status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error'); + return; + } + + var choice; + try { + choice = await openStagePicker({ fileCount: 1, folders: folders }); + } catch (_e) { return; } + + if (choice.create) { + try { + await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/'); + } catch (e) { + status((e && e.message) || 'mkdir failed', 'error'); + return; + } + } + + var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name); + try { + await moveFile(srcUrl, dstUrl); + } catch (e) { + status((e && e.message) || 'move failed', 'error'); + return; + } + status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success'); + } + + async function invokeUnstage(node) { + var tree = window.app.modules.tree; + if (!tree) return; + var srcUrl = tree.pathFor(node); + var info = projectAndSubtree(srcUrl); + if (!info || info.subtree !== 'staging') { + status('Unstage applies only to files under staging/.', 'error'); + return; + } + var email = await fetchSelfEmail(); + var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/'; + var choice; + try { + choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget }); + } catch (_e) { return; } + var target = choice.target; + if (!target.endsWith('/')) target += '/'; + var dstUrl = target + encodeURIComponent(node.name); + try { + await moveFile(srcUrl, dstUrl); + } catch (e) { + status((e && e.message) || 'move failed', 'error'); + return; + } + status('Unstaged ' + node.name + ' → ' + target + ' — reload to see the move.', 'success'); + } + + window.app.modules.stage = { + isStageableFile: isStageableFile, + isUnstageableFile: isUnstageableFile, + invokeStage: invokeStage, + invokeUnstage: invokeUnstage + }; +})(); diff --git a/browse/js/upload.js b/browse/js/upload.js index dcb6691..fa52c7f 100644 --- a/browse/js/upload.js +++ b/browse/js/upload.js @@ -238,6 +238,71 @@ return { ok: ok, fail: fail }; } + // Comment upload: PUT each dropped file's bytes to the target URL. + // The server detects the virtual /received/ context and + // rewrites the destination to /+C, surfacing + // the resolved path in X-ZDDC-Resolved-Path so the status line can + // tell the user where the bytes landed. + async function uploadCommentToTarget(targetURL, dataTransfer) { + var note = window.zddc && window.zddc.toast; + var files = []; + if (dataTransfer.files && dataTransfer.files.length) { + for (var k = 0; k < dataTransfer.files.length; k++) { + files.push(dataTransfer.files[k]); + } + } + if (files.length === 0) { + if (note) note('No files to upload.', 'warning'); + return; + } + var ok = 0; + var lastResolved = ''; + for (var i = 0; i < files.length; i++) { + var f = files[i]; + if (f.size > UPLOAD_MAX_BYTES) { + if (note) note('Skipped (too large): ' + f.name, 'error'); + continue; + } + try { + var resp = await fetch(targetURL, { + method: 'PUT', + body: f, + credentials: 'same-origin', + headers: { 'Content-Type': f.type || 'application/octet-stream' } + }); + if (resp.ok) { + ok++; + var hdr = resp.headers.get('X-ZDDC-Resolved-Path') || ''; + if (hdr) lastResolved = hdr; + } else if (note) { + note('Comment upload failed (' + resp.status + ')', 'error'); + } + } catch (e) { + if (note) note('Comment upload error: ' + (e && e.message), 'error'); + } + } + if (note && ok > 0) { + var msg = 'Saved ' + ok + ' comment' + (ok === 1 ? '' : 's'); + if (lastResolved) msg += ' — last at ' + lastResolved; + note(msg, 'success'); + } + // Reload the listing of the workflow folder so the new +Cn file + // appears in the tree. The workflow folder is the parent of the + // virtual `received/` (i.e., the URL with one `/received/` + // suffix stripped). + var refreshUrl = targetURL.replace(/\/received\/[^/]+\/?$/, '/'); + try { + var ev = window.app.modules.events; + if (ev && typeof ev.refreshListing === 'function') { + ev.refreshListing(); + } else if (refreshUrl) { + // Best-effort fallback: re-navigate to the workflow folder + // so its listing is refreshed. + // (No action — refreshListing absence implies older browse.) + } + } catch (_e) { /* refresh is best-effort */ } + } + // ── Create-new helpers ──────────────────────────────────────────────── // Both go through the same server endpoints used by upload: PUT // for files (with an empty/template body) and POST + X-ZDDC-Op: @@ -475,6 +540,7 @@ window.app.modules.upload = { currentScopeAllows: currentScopeAllows, uploadToDir: uploadToDir, + uploadCommentToTarget: uploadCommentToTarget, makeDir: makeDir, makeFile: makeFile, removeNode: removeNode, diff --git a/shared/zddc.js b/shared/zddc.js index 1166b4d..fba60e3 100644 --- a/shared/zddc.js +++ b/shared/zddc.js @@ -37,6 +37,7 @@ 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', + 'TBD', ]; var STATUS_SET = {}; diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 8b586ae..ec58ae8 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -1079,37 +1079,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } } } - // Reviewing aggregator. /reviewing/[/] is - // a virtual view. The shape rule mirrors the other canonical - // folders (slash → browse, no-slash → default tool): - // - JSON request, any depth → aggregator listing (handler.ServeReviewing) - // - HTML, no slash → browse (default tool, via DefaultAppAt; - // browse hosts the markdown editor plugin) - // - HTML, with slash → browse.html (via ServeDirectory). - // browse fetches JSON which routes back - // through here to ServeReviewing. - // Depth-3 no-slash (reviewing/) 302s to the slash form. - // Depth-2 no-slash (reviewing) falls through to the canonical- - // folder block below where DefaultAppAt routes to browse. + // reviewing/ is no longer a virtual aggregator — it's a normal + // directory under each project, populated by the Plan Review + // composite endpoint with physical workflow folders. Falls + // through to the canonical-folder block below. + // + // Virtual received/ window. /received/[...] is a + // synthetic view onto the canonical received// + // declared by the workflow folder's .zddc.received_path. + // ResolveVirtualReceived validates the parent .zddc; on a + // match, route through the normal directory/file handlers, + // which swap the read source to the canonical based on the + // URL (ListDirectory and ServeFile via the absolute path). if r.Method == http.MethodGet || r.Method == http.MethodHead { - if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok { - if !strings.HasSuffix(urlPath, "/") { - if tracking != "" { - http.Redirect(w, r, urlPath+"/", http.StatusFound) - return - } - // Depth-2 no-slash falls through to canonical-folder block. - } else if strings.Contains(r.Header.Get("Accept"), "application/json") { - chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Join(cfg.Root, proj)) - if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - handler.ServeReviewing(cfg, w, r, proj, tracking, sidePath) + if vr := zddc.ResolveVirtualReceived(cfg.Root, urlPath); vr.Resolved { + if strings.HasSuffix(urlPath, "/") { + handler.ServeDirectory(cfg, appsSrv, w, r) return } - // HTML trailing-slash falls through to canonical-folder - // block → ServeDirectory → embedded browse.html. + // File read — ACL-check against the canonical + // received's chain, then serve the canonical bytes + // while keeping the workflow URL in the address bar. + chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(vr.ReceivedAbs)) + if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + handler.ServeFile(w, r, vr.ReceivedAbs) + return } } // Cascade-declared paths: the .zddc cascade (embedded diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index ee8f87e..fa9c93d 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -47,6 +47,17 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, return nil, os.ErrNotExist } + // Virtual received/ window: when the URL points at /received/ + // (i.e. the URL traverses a `received` segment whose workflow-folder + // parent declares received_path in its .zddc), redirect the listing + // source to the canonical received// path. Entry URLs stay + // rooted at baseURL so the browse client keeps the workflow context — + // drag-drop onto an entry here PUTs to /received/, + // which serveFilePut intercepts and rewrites to /+C. + if vr := zddc.ResolveVirtualReceived(fsRoot, strings.TrimSuffix(baseURL, "/")); vr.Resolved && vr.IsRoot { + absDir = vr.ReceivedAbs + } + entries, err := os.ReadDir(absDir) if err != nil { // Empty-listing fallback for cascade-declared paths. A fresh @@ -171,6 +182,31 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // to real ones. result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...) + // Workflow folder: append a virtual `received/` entry whose backing + // is .zddc.received_path. The entry's URL stays under the workflow + // folder (baseURL + "received/") so a click navigates "into" the + // synthetic child — the listing handler then swaps the read source + // to the canonical received// path while keeping the URL + // context intact. Suppressed if a real `received/` already exists on + // disk (operator override). + if rp := zddc.WorkflowReceivedPath(absDir); rp != "" { + hasReal := false + for _, fi := range result { + if fi.IsDir && strings.EqualFold(strings.TrimSuffix(fi.Name, "/"), "received") { + hasReal = true + break + } + } + if !hasReal { + result = append(result, listing.FileInfo{ + Name: "received/", + URL: baseURL + "received/", + IsDir: true, + Virtual: true, + }) + } + } + // Surface a virtual `.zddc` entry when the on-disk file doesn't // exist. The //.zddc URL always serves SOMETHING — real // bytes if present, a synthetic placeholder body otherwise (see diff --git a/zddc/internal/handler/accepthandler.go b/zddc/internal/handler/accepthandler.go new file mode 100644 index 0000000..d4c8154 --- /dev/null +++ b/zddc/internal/handler/accepthandler.go @@ -0,0 +1,274 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// Accept Transmittal — the doc-controller's "file a counterparty +// upload into the immutable received archive" step. Right-click on a +// single transmittal folder under archive//incoming/ in the +// browse app; the client POSTs X-ZDDC-Op: accept-transmittal with the +// body below. +// +// Authorisation model — same primitives as Plan Review, no exceptions: +// +// - ActionWrite on incoming// (move source). +// document_controller has rwcd on incoming/ via the cascade defaults. +// - ActionCreate on received// (move destination, WORM zone). +// document_controller has `cr` here via worm: [document_controller]. +// +// Operation: +// +// 1. Parse URL — must be a direct child of archive//incoming/. +// 2. Validate the transmittal folder name via ParseTransmittalFolder +// (date, tracking, status, title). Reject if not well-formed. +// 3. Validate every file in the folder via ParseFilename. Each file's +// parsed tracking must match the folder's tracking. Reject on any +// non-conformance — client should cancel and tell sender to fix. +// 4. ACL pre-flight (source write, destination create). +// 5. mkdir received/ (parent of the destination) if missing. +// 6. If received// does NOT exist → os.Rename the whole +// folder (atomic, fast). +// If received// DOES exist (re-submission of the same +// tracking) → per-file move. Refuse if any child filename already +// exists at the destination — WORM forbids overwrite. +// 7. Optional Plan Review chain: when the body's setup_plan_review +// flag is true, the same handler dispatches through Plan Review's +// three-stage flow against the new received// URL. The +// ACL gates re-run there (idempotent against the same principal), +// which is correct: both authorities are required by design. +// +// The accept itself does NOT write received//.zddc — the +// cascade's worm: [document_controller] inheritance is enough. If +// Plan Review is chained, IT writes the .zddc with planned dates. +// Filesystem mtime on the moved folder records when the accept +// happened; the audit log records who. + +const opAcceptTransmittal = "accept-transmittal" + +// incomingURLPattern matches //archive//incoming//. +var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/incoming/([^/]+)/?$`) + +type acceptRequest struct { + ReceivedDate string `yaml:"received_date"` + SetupPlanReview bool `yaml:"setup_plan_review"` + ReviewLead string `yaml:"review_lead"` + Approver string `yaml:"approver"` + PlanReviewCompleteDate string `yaml:"plan_review_complete_date"` + PlanResponseDate string `yaml:"plan_response_date"` +} + +type acceptResponse struct { + Tracking string `json:"tracking"` + IncomingPath string `json:"incoming_path"` + ReceivedPath string `json:"received_path"` + MovedFiles int `json:"moved_files"` + Merged bool `json:"merged"` + PlanReview *planReviewResponse `json:"plan_review,omitempty"` +} + +func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Request) { + cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/" + m := incomingURLPattern.FindStringSubmatch(cleanURL) + if m == nil { + http.Error(w, "Bad Request — accept-transmittal must POST to //archive//incoming//", http.StatusBadRequest) + return + } + project, party, transmittalFolder := m[1], m[2], m[3] + + date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder) + if !ok { + http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_ () - )", http.StatusBadRequest) + return + } + _ = date // available for audit; mtime carries the actual accept time + + body, ok2 := readBodyCapped(cfg, w, r) + if !ok2 { + return + } + var req acceptRequest + if len(body) > 0 { + if err := yaml.Unmarshal(body, &req); err != nil { + http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest) + return + } + } + if req.SetupPlanReview { + if req.ReviewLead == "" || req.Approver == "" || + req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" { + http.Error(w, "Bad Request — setup_plan_review requires review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest) + return + } + } + + incomingAbs := filepath.Join(cfg.Root, project, "archive", party, "incoming", transmittalFolder) + receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking) + receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/" + + // Source must exist as a directory. + srcInfo, err := os.Stat(incomingAbs) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "Not Found", http.StatusNotFound) + } else { + http.Error(w, "Internal Server Error — stat source: "+err.Error(), http.StatusInternalServerError) + } + return + } + if !srcInfo.IsDir() { + http.Error(w, "Bad Request — accept-transmittal target is not a directory", http.StatusBadRequest) + return + } + + // Validate every file in the folder before any side-effect. + entries, err := os.ReadDir(incomingAbs) + if err != nil { + http.Error(w, "Internal Server Error — read source: "+err.Error(), http.StatusInternalServerError) + return + } + var fileNames []string + var violations []string + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".") { + continue // skip dotfiles silently (e.g. .zddc dropped by counterparty) + } + if e.IsDir() { + violations = append(violations, name+": nested directories are not permitted in a transmittal folder") + continue + } + parsed := zddc.ParseFilename(name) + if !parsed.Valid { + violations = append(violations, name+": does not conform to ZDDC filename grammar") + continue + } + if parsed.TrackingNumber != tracking { + violations = append(violations, fmt.Sprintf("%s: tracking %q does not match folder tracking %q", name, parsed.TrackingNumber, tracking)) + continue + } + fileNames = append(fileNames, name) + } + if len(violations) > 0 { + http.Error(w, "Conflict — transmittal folder contents do not conform:\n"+strings.Join(violations, "\n"), http.StatusConflict) + return + } + if len(fileNames) == 0 { + http.Error(w, "Conflict — transmittal folder is empty", http.StatusConflict) + return + } + + // ACL pre-flight: source needs Write (rename out), destination needs Create. + if !authorizeAction(cfg, w, r, incomingAbs, cleanURL, policy.ActionWrite) { + return + } + if !authorizeAction(cfg, w, r, receivedAbs, receivedURL, policy.ActionCreate) { + return + } + + email := EmailFromContext(r) + if email == "" { + http.Error(w, "Forbidden — no authenticated principal", http.StatusForbidden) + return + } + + // Ensure received/'s parent exists (received/ itself materialises via + // the rename or the per-file moves below). + receivedParent := filepath.Dir(receivedAbs) + if err := os.MkdirAll(receivedParent, 0o755); err != nil { + auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error — mkdir received/: "+err.Error(), http.StatusInternalServerError) + return + } + + merged := false + if _, err := os.Stat(receivedAbs); err == nil { + // Re-submission of an already-accepted tracking → merge per-file. + // Refuse any filename collision; WORM forbids overwriting. + merged = true + for _, name := range fileNames { + dst := filepath.Join(receivedAbs, name) + if _, statErr := os.Stat(dst); statErr == nil { + http.Error(w, "Conflict — "+name+" already exists in received/"+tracking+"/ (WORM forbids overwrite)", http.StatusConflict) + return + } else if !errors.Is(statErr, os.ErrNotExist) { + http.Error(w, "Internal Server Error — stat destination: "+statErr.Error(), http.StatusInternalServerError) + return + } + } + for _, name := range fileNames { + src := filepath.Join(incomingAbs, name) + dst := filepath.Join(receivedAbs, name) + if err := os.Rename(src, dst); err != nil { + auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error — rename "+name+": "+err.Error(), http.StatusInternalServerError) + return + } + } + // Best-effort: remove the now-empty incoming folder. Leaves it in + // place if non-empty (e.g. operator left ad-hoc notes alongside + // the conformant files); audit log captures the success either way. + _ = os.Remove(incomingAbs) + } else if errors.Is(err, os.ErrNotExist) { + // Fresh acceptance → atomic folder rename. + if err := os.Rename(incomingAbs, receivedAbs); err != nil { + auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error — rename folder: "+err.Error(), http.StatusInternalServerError) + return + } + } else { + http.Error(w, "Internal Server Error — stat received: "+err.Error(), http.StatusInternalServerError) + return + } + + resp := acceptResponse{ + Tracking: tracking, + IncomingPath: cleanURL, + ReceivedPath: receivedURL, + MovedFiles: len(fileNames), + Merged: merged, + } + + // Optional Plan Review chain. Invokes executePlanReview directly + // against the freshly-created received/<tracking>/ path. The ACL + // gates re-run there — the invoker still needs CanEditZddc on the + // workflow roots and `c` on received/<tracking>/, both of which + // they had a moment ago for the move itself. A chained failure does + // NOT roll back the move: the canonical record is sealed, and the + // user can re-trigger Plan Review later from the received/<tracking>/ + // folder context menu. + if req.SetupPlanReview { + planReq := planReviewRequest{ + ReviewLead: req.ReviewLead, + Approver: req.Approver, + PlanReviewCompleteDate: req.PlanReviewCompleteDate, + PlanResponseDate: req.PlanResponseDate, + } + prResp, status, msg := executePlanReview(cfg, r, project, party, tracking, planReq) + if status != http.StatusOK { + auditFile(r, "accept-transmittal", cleanURL, status, 0, errors.New(msg)) + http.Error(w, "Chained plan-review: "+msg, status) + return + } + resp.PlanReview = prResp + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-ZDDC-Source", "fileapi:accept-transmittal") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + auditFile(r, "accept-transmittal", cleanURL+" -> "+receivedURL, http.StatusOK, 0, nil) +} diff --git a/zddc/internal/handler/accepthandler_test.go b/zddc/internal/handler/accepthandler_test.go new file mode 100644 index 0000000..75a4b94 --- /dev/null +++ b/zddc/internal/handler/accepthandler_test.go @@ -0,0 +1,193 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// acceptSetup writes a tree with a conforming transmittal folder under +// archive/Acme/incoming/ and an admin grant for alice. Returns the cfg, +// a do() helper, and the root path. +func acceptSetup(t *testing.T) (config.Config, func(target, email string, body []byte) *httptest.ResponseRecorder, string) { + t.Helper() + root := t.TempDir() + mustWriteHelper(t, filepath.Join(root, ".zddc"), + "admins:\n - alice@example.com\n"+ + "roles:\n document_controller:\n members: [alice@example.com]\n") + for _, d := range []string{"Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation"} { + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + // Seed two conforming files inside the transmittal folder. + transmittalDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation") + mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Foundation.pdf"), "%PDF-") + mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Cover Letter.pdf"), "%PDF-") + zddc.InvalidateCache(root) + + cfg := config.Config{ + Root: root, + EmailHeader: "X-Auth-Request-Email", + MaxWriteBytes: 64 * 1024, + } + do := func(target, email string, body []byte) *httptest.ResponseRecorder { + // target may contain spaces and parens (real transmittal folder + // names do); construct the URL from a url.URL so the request line + // gets properly escaped and r.URL.Path comes back decoded for the + // handler's pattern match. + u := &url.URL{Path: target} + req := httptest.NewRequest(http.MethodPost, u.RequestURI(), bytes.NewReader(body)) + req.Header.Set(headerOp, opAcceptTransmittal) + req.Header.Set("Content-Type", "application/yaml") + ctx := context.WithValue(req.Context(), EmailKey, email) + ctx = context.WithValue(ctx, ElevatedKey, true) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + ServeFileAPI(cfg, rec, req) + return rec + } + return cfg, do, root +} + +// TestAccept_FreshAcceptance — a conforming transmittal folder moves +// from incoming/ to received/, renamed to tracking-only. +func TestAccept_FreshAcceptance(t *testing.T) { + _, do, root := acceptSetup(t) + target := "/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/" + rec := do(target, "alice@example.com", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp acceptResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v; body=%s", err, rec.Body.String()) + } + if resp.Tracking != "Acme-0042" { + t.Errorf("Tracking=%q, want Acme-0042", resp.Tracking) + } + if resp.MovedFiles != 2 { + t.Errorf("MovedFiles=%d, want 2", resp.MovedFiles) + } + if resp.Merged { + t.Errorf("Merged=true, want false on fresh acceptance") + } + // Folder should be at received/Acme-0042/, not the transmittal name. + if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Foundation.pdf")); err != nil { + t.Errorf("primary file not moved into received/: %v", err) + } + // Source should no longer exist. + if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); !os.IsNotExist(err) { + t.Errorf("source folder still present after rename") + } +} + +// TestAccept_NonConformingFilename — a file inside the transmittal +// folder that doesn't parse rejects the whole accept and leaves the +// source untouched. +func TestAccept_NonConformingFilename(t *testing.T) { + _, do, root := acceptSetup(t) + // Drop a bad file alongside the good ones. + mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/random-notes.txt"), "oops") + rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil) + if rec.Code != http.StatusConflict { + t.Fatalf("status=%d, want 409; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "random-notes.txt") { + t.Errorf("error body should name the violating file; got %s", rec.Body.String()) + } + // Source untouched. + if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); err != nil { + t.Errorf("source folder removed despite rejection: %v", err) + } +} + +// TestAccept_NonConformingFolderName — a transmittal folder whose +// name doesn't parse rejects with 400 (the URL pattern matches the +// outer shape but the folder grammar fails). +func TestAccept_NonConformingFolderName(t *testing.T) { + _, do, root := acceptSetup(t) + badDir := filepath.Join(root, "Project-1/archive/Acme/incoming/bad-folder-name") + if err := os.MkdirAll(badDir, 0o755); err != nil { + t.Fatal(err) + } + rec := do("/Project-1/archive/Acme/incoming/bad-folder-name/", "alice@example.com", nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +// TestAccept_PlanReviewChain — setup_plan_review: true chains into +// Plan Review and reports both results in the response. +func TestAccept_PlanReviewChain(t *testing.T) { + _, do, root := acceptSetup(t) + body := []byte(strings.Join([]string{ + "setup_plan_review: true", + "review_lead: bob@vendor.com", + "approver: carol@example.com", + "plan_review_complete_date: 2026-05-30", + "plan_response_date: 2026-06-15", + "", + }, "\n")) + rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", body) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp acceptResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.PlanReview == nil { + t.Fatalf("PlanReview chain absent in response: %+v", resp) + } + if !resp.PlanReview.Reviewing.Created || !resp.PlanReview.Staging.Created { + t.Errorf("chained Plan Review did not converge: %+v", resp.PlanReview) + } + // received/.zddc must exist (Plan Review writes it). + if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc")); err != nil { + t.Errorf("received .zddc not written by chained Plan Review: %v", err) + } +} + +// TestAccept_Merge — a second acceptance of the same tracking with +// distinct filenames merges into the existing received/<tracking>/ +// folder. Re-using a filename is rejected by WORM. +func TestAccept_Merge(t *testing.T) { + _, do, root := acceptSetup(t) + rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil) + if rec.Code != http.StatusOK { + t.Fatalf("first accept status=%d, want 200; body=%s", rec.Code, rec.Body.String()) + } + // Build a second transmittal folder with the same tracking but a + // distinct rev so the filenames don't collide. + secondDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup") + if err := os.MkdirAll(secondDir, 0o755); err != nil { + t.Fatal(err) + } + mustWriteHelper(t, filepath.Join(secondDir, "Acme-0042_B (RFI) - Foundation.pdf"), "%PDF-") + rec = do("/Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup/", "alice@example.com", nil) + if rec.Code != http.StatusOK { + t.Fatalf("second accept status=%d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp acceptResponse + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if !resp.Merged { + t.Errorf("Merged=false on re-acceptance of same tracking; want true") + } + // Both revs should now live in received/Acme-0042/. + for _, name := range []string{"Acme-0042_A (RFI) - Foundation.pdf", "Acme-0042_B (RFI) - Foundation.pdf"} { + if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042", name)); err != nil { + t.Errorf("expected %s in merged received/: %v", name, err) + } + } +} diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index 0394028..e399d22 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -147,6 +147,22 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" { w.Header().Set("X-ZDDC-Default-Tool", dt) } + // X-ZDDC-On-Plan-Review surfaces whether the cascade above this + // path has an on_plan_review block configured. Browse uses it to + // show/hide the "Plan Review" right-click menu item without + // re-implementing the cascade client-side. Boolean; absent header + // = false. + if zddc.OnPlanReviewAt(cfg.Root, absDir) != nil { + w.Header().Set("X-ZDDC-On-Plan-Review", "true") + } + // X-ZDDC-Canonical-Folder names the canonical project-layout slot + // this directory occupies — "incoming", "received", "working", + // "staging", etc. Drives scope-aware context-menu visibility for + // Accept Transmittal, Stage/Unstage, and Create Transmittal folder. + // Absent header means the directory is not at a canonical slot. + if cf := zddc.CanonicalFolderAt(cfg.Root, absDir); cf != "" { + w.Header().Set("X-ZDDC-Canonical-Folder", cf) + } if strings.Contains(accept, "application/json") { // Content-hash ETag on the listing payload. Re-fetched on every diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index c5de9d3..41cf57b 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -321,6 +321,49 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } + // Virtual received/ rewrite. When the PUT targets a file under the + // synthetic <workflow>/received/<file> URL, the canonical record is + // WORM — we can't write there. Convention: treat the drop as a + // review comment, write it into the workflow folder as + // <base>+C<n><suffix> where n increments past any existing comments + // on the same target. The target filename comes from the URL's + // final segment. + if vr := zddc.ResolveVirtualReceived(cfg.Root, cleanURL); vr.Resolved && !vr.IsRoot { + targetName := filepath.Base(vr.SuffixURL) + commentName, cerr := zddc.CommentResolvedName(vr.WorkflowAbs, targetName) + if cerr != nil { + http.Error(w, "Bad Request — comment upload requires a ZDDC-parseable target filename: "+cerr.Error(), http.StatusBadRequest) + return + } + // Race-fix: if the computed filename already exists (concurrent + // upload), step the counter forward until we find a free slot. + abs = filepath.Join(vr.WorkflowAbs, commentName) + for i := 0; i < 32; i++ { + if _, err := os.Stat(abs); errors.Is(err, os.ErrNotExist) { + break + } else if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + // Bump: recompute with one more existing sibling. + commentName, cerr = zddc.CommentResolvedName(vr.WorkflowAbs, targetName) + if cerr != nil { + http.Error(w, "Internal Server Error — comment counter: "+cerr.Error(), http.StatusInternalServerError) + return + } + abs = filepath.Join(vr.WorkflowAbs, commentName) + } + // Rewrite cleanURL so audit logs + response headers reflect + // the actual destination, not the virtual one. Surface to the + // client via X-ZDDC-Resolved-Path so the status line can show + // "Saved as <resolved name>". + cleanURL = vr.WorkflowURL + commentName + w.Header().Set("X-ZDDC-Resolved-Path", cleanURL) + // Continue with normal write flow — ACL on the workflow folder + // gates the write, and existed=false (new file) selects + // ActionCreate. + } + // Resolve canonical-folder casing on the way in (no side effects): a // request for /Project/working/foo.md when the on-disk folder is // Working/ should land in Working/, not create a duplicate sibling. @@ -450,6 +493,10 @@ func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) { serveFileMove(cfg, w, r) case opMkdir: serveFileMkdir(cfg, w, r) + case opPlanReview: + servePlanReview(cfg, w, r) + case opAcceptTransmittal: + serveAcceptTransmittal(cfg, w, r) case "": http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest) default: diff --git a/zddc/internal/handler/planreview.go b/zddc/internal/handler/planreview.go new file mode 100644 index 0000000..ba2c4fa --- /dev/null +++ b/zddc/internal/handler/planreview.go @@ -0,0 +1,449 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// Plan Review — the doc-controller's "establish the canonical record" +// step. Right-click on archive/<party>/received/<tracking>/ in the +// browse app; the client POSTs X-ZDDC-Op: plan-review with the body +// below. +// +// Authorisation model — no ACL exception, only existing grants: +// +// - Create authority on received/<tracking>/. The doc_controller +// gets this from `worm: [document_controller]` on received/ in the +// cascade defaults; the same `c` (write-once-create) verb that +// lets them file canonical submittals lets them establish this +// .zddc once. +// - CanEditZddc on reviewing_root + staging_root. Existing rule +// from the cascade defaults. +// +// Operation: +// +// 1. Workflow folders converge first (idempotent — match by +// .zddc.received_path; mkdir if missing; rewrite workflow .zddc +// with received_path + ACL). +// 2. Write received/<tracking>/.zddc — but only if it doesn't exist. +// The .zddc schema is server-constrained to {planned_review_date, +// planned_response_date, created_by} — no ACL, admins, or other +// fields, so this write cannot escalate the invoker's authority. +// If the file already exists, the canonical record is sealed; the +// dates in the request are ignored and the workflow folders are +// converged on top. +// +// So Plan Review's first run establishes the canonical commitment; +// subsequent runs can only re-converge the workflow ACLs (e.g. swap +// review lead). The planned dates are write-once — to change them, an +// admin must edit received/<tracking>/.zddc directly via their admin +// authority (which under the cascade defaults is nobody beneath the +// root admin; deliberate). + +const opPlanReview = "plan-review" + +// planReviewRequest is the YAML body the browse client POSTs. +type planReviewRequest struct { + ReviewLead string `yaml:"review_lead"` + Approver string `yaml:"approver"` + PlanReviewCompleteDate string `yaml:"plan_review_complete_date"` + PlanResponseDate string `yaml:"plan_response_date"` +} + +// planReviewResponse is the JSON returned to the client. +type planReviewResponse struct { + Tracking string `json:"tracking"` + Title string `json:"title"` + Reviewing planReviewFolderOK `json:"reviewing"` + Staging planReviewFolderOK `json:"staging"` + Received planReviewFolderOK `json:"received"` +} + +type planReviewFolderOK struct { + Path string `json:"path"` + Created bool `json:"created"` + ZddcWritten bool `json:"zddc_written"` +} + +// receivedURLPattern matches /<project>/archive/<party>/received/<tracking>/ +// — Plan Review is only valid at that depth. Trailing slash required. +var receivedURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/received/([^/]+)/?$`) + +func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request) { + // 1. URL must be a received-tracking folder. + cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/" + m := receivedURLPattern.FindStringSubmatch(cleanURL) + if m == nil { + http.Error(w, "Bad Request — plan-review must POST to /<project>/archive/<party>/received/<tracking>/", http.StatusBadRequest) + return + } + project, party, tracking := m[1], m[2], m[3] + + // 2. Body parse. + body, ok := readBodyCapped(cfg, w, r) + if !ok { + return + } + var req planReviewRequest + if err := yaml.Unmarshal(body, &req); err != nil { + http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest) + return + } + if req.ReviewLead == "" || req.Approver == "" || + req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" { + http.Error(w, "Bad Request — body must include review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest) + return + } + + resp, status, msg := executePlanReview(cfg, r, project, party, tracking, req) + if status != http.StatusOK { + auditFile(r, "plan-review", cleanURL, status, 0, nil) + http.Error(w, msg, status) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-ZDDC-Source", "fileapi:plan-review") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + auditFile(r, "plan-review", cleanURL, http.StatusOK, 0, nil) +} + +// executePlanReview runs the Plan Review three-stage flow against an +// already-resolved received/<tracking>/ path. URL and body parsing +// happen in the caller. Returns the response struct on success; +// non-200 (status, message) on auth or execution failure. The caller +// is responsible for writing the HTTP response. +// +// Exposed so accept-transmittal can chain Plan Review in the same +// request without round-tripping through HTTP. +func executePlanReview(cfg config.Config, r *http.Request, project, party, tracking string, req planReviewRequest) (*planReviewResponse, int, string) { + receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking)) + receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel)) + cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/" + + prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs) + if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" { + return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree" + } + reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/"))) + stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/"))) + + // Pre-flight authorisation. No ACL exception — we use existing + // cascade grants: + // (a) CanEditZddc on reviewing_root and staging_root proves the + // invoker is subtree-admin of the workflow roots and can + // write the workflow .zddc files. + // (b) The invoker has `c` (write-once-create) authority on + // received/<tracking>/. For the doc_controller this comes + // from `worm: [document_controller]` on received/ in the + // cascade defaults — the same authority that lets them file + // canonical submittals lets them establish this .zddc once. + p := PrincipalFromContext(r) + email := EmailFromContext(r) + if email == "" { + return nil, http.StatusForbidden, "Forbidden — no authenticated principal" + } + for _, root := range []string{reviewingRoot, stagingRoot} { + if !zddc.CanEditZddc(cfg.Root, root, p) { + return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks subtree-admin authority for %s", + email, strings.TrimPrefix(root, cfg.Root+string(filepath.Separator))) + } + } + // (b) — verify `c` authority on received/<tracking>/. Admins bypass + // the policy and would pass anyway; non-admin doc_controllers come + // through the WORM-list grant. + if !zddc.IsAdmin(cfg.Root, p) && !zddc.IsSubtreeAdmin(cfg.Root, receivedAbs, p) { + chain, perr := zddc.EffectivePolicy(cfg.Root, receivedAbs) + if perr != nil { + return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error() + } + allowed, _ := policy.AllowActionFromChain(r.Context(), DeciderFromContext(r), chain, email, cleanURL, policy.ActionCreate) + if !allowed { + return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks create authority on %s (filing this submittal requires the doc_controller WORM grant)", + email, strings.TrimPrefix(receivedAbs, cfg.Root+string(filepath.Separator))) + } + } + + // Derive a title from received/<tracking>/'s contents — first + // ZDDC-parseable filename's title field wins. Fallback to the + // tracking number itself so the folder name always has a tail. + title := deriveTitleFromReceived(receivedAbs) + if title == "" { + title = tracking + } + + // Materialise roots + received/<tracking>/ ancestors (the received + // folder itself was created when the doc controller moved the + // submittal in; defensive ensure here for tests). + for _, root := range []string{reviewingRoot, stagingRoot, receivedAbs} { + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, http.StatusInternalServerError, "Internal Server Error — ensure dirs: " + err.Error() + } + } + + // received/<tracking>/.zddc is WRITE-ONCE — the canonical commitment. + // First-run creates it under the invoker's WORM-`c` authority + // (verified above); subsequent runs leave it alone and the request's + // date fields are ignored. The schema is server-constrained: only + // planned_review_date + planned_response_date + created_by are written. + // No ACL, admins, or other content — so this write cannot escalate + // the invoker's authority. + receivedResult, err := establishReceivedPlanDates(receivedAbs, req.PlanReviewCompleteDate, req.PlanResponseDate, email, cfg.Root) + if err != nil { + return nil, http.StatusInternalServerError, "Internal Server Error — received .zddc: " + err.Error() + } + + // Converge the workflow folders. + reviewingResult, err := convergeWorkflowFolder(workflowConverge{ + fsRoot: cfg.Root, + root: reviewingRoot, + forecast: req.PlanReviewCompleteDate, + tracking: tracking, + title: title, + receivedRel: receivedRel, + acl: map[string]string{req.ReviewLead: "rwcda"}, + creatorEmail: email, + }) + if err != nil { + return nil, http.StatusInternalServerError, "Internal Server Error — reviewing convergence: " + err.Error() + } + + stagingResult, err := convergeWorkflowFolder(workflowConverge{ + fsRoot: cfg.Root, + root: stagingRoot, + forecast: req.PlanResponseDate, + tracking: tracking, + title: title, + receivedRel: receivedRel, + acl: map[string]string{req.Approver: "rwcda"}, + creatorEmail: email, + }) + if err != nil { + return nil, http.StatusInternalServerError, "Internal Server Error — staging convergence: " + err.Error() + } + + return &planReviewResponse{ + Tracking: tracking, + Title: title, + Reviewing: planReviewFolderOK{ + Path: "/" + filepath.ToSlash(reviewingResult.relPath) + "/", + Created: reviewingResult.created, + ZddcWritten: reviewingResult.zddcWritten, + }, + Staging: planReviewFolderOK{ + Path: "/" + filepath.ToSlash(stagingResult.relPath) + "/", + Created: stagingResult.created, + ZddcWritten: stagingResult.zddcWritten, + }, + Received: planReviewFolderOK{ + Path: "/" + filepath.ToSlash(receivedResult.relPath) + "/", + Created: receivedResult.created, + ZddcWritten: receivedResult.zddcWritten, + }, + }, http.StatusOK, "" +} + +// establishReceivedPlanDates writes received/<tracking>/.zddc with the +// committed planned dates iff the file doesn't yet exist. If it does, +// the canonical record is already sealed and the call is a no-op +// (zddcWritten=false in the result); the request's date fields are +// silently ignored on subsequent runs. The schema is server-constrained +// to just the two date fields + created_by — no ACL or admin grants. +func establishReceivedPlanDates(receivedAbs, planReview, planResponse, creatorEmail, fsRoot string) (workflowResult, error) { + var res workflowResult + res.absPath = receivedAbs + if rel, err := filepath.Rel(fsRoot, receivedAbs); err == nil { + res.relPath = filepath.ToSlash(rel) + } else { + res.relPath = receivedAbs + } + zddcPath := filepath.Join(receivedAbs, ".zddc") + if _, err := os.Stat(zddcPath); err == nil { + // Sealed — leave alone. zddcWritten stays false. + return res, nil + } else if !errors.Is(err, os.ErrNotExist) { + return res, err + } + zf := zddc.ZddcFile{ + PlannedReviewDate: planReview, + PlannedResponseDate: planResponse, + CreatedBy: creatorEmail, + } + if err := zddc.WriteFile(receivedAbs, zf); err != nil { + return res, err + } + res.zddcWritten = true + res.created = true // first-time establishment + return res, nil +} + +// deriveTitleFromReceived scans received/<tracking>/ for ZDDC-parseable +// filenames and returns the first one's title field. Empty if no +// parseable file is found. +func deriveTitleFromReceived(receivedAbs string) string { + entries, err := os.ReadDir(receivedAbs) + if err != nil { + return "" + } + // Sort for deterministic title selection (first alphabetical wins). + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + names = append(names, e.Name()) + } + sort.Strings(names) + for _, name := range names { + parsed := zddc.ParseFilename(name) + if parsed.Valid && parsed.Title != "" { + return parsed.Title + } + } + return "" +} + +// workflowConverge captures the parameters for converging a single +// reviewing/ or staging/ workflow folder. +type workflowConverge struct { + fsRoot string // master root (cfg.Root) — used to compute response paths + root string // absolute path of reviewing_root or staging_root + forecast string // initial forecast date for the folder name (YYYY-MM-DD) + tracking string // tracking number + title string // derived title + receivedRel string // relative path to canonical submittal, e.g. archive/Acme/received/Acme-0042 + acl map[string]string // per-folder ACL grants (principal → verb-set) + creatorEmail string // creator/audit email +} + +// workflowResult is the post-convergence summary for one folder. +type workflowResult struct { + relPath string // server-relative path (no leading slash, no trailing slash) + absPath string + created bool // true iff this convergence run mkdir'd the folder + zddcWritten bool // true iff a .zddc was written (always true on success) +} + +// convergeWorkflowFolder converges one of the workflow folders (reviewing +// or staging) toward the desired state. Idempotent on re-run. +func convergeWorkflowFolder(c workflowConverge) (workflowResult, error) { + var res workflowResult + + // Search the root for an existing folder whose .zddc.received_path + // matches. If found, use it — the user controls the folder name via + // direct rename, so we don't fight their date. + existing, err := findWorkflowFolderByReceivedPath(c.root, c.receivedRel) + if err != nil { + return res, err + } + + target := existing + if target == "" { + // No match — mkdir at <root>/<forecast>_<tracking> (TBD) - <title>/. + // Append _2, _3 to disambiguate exact-name collisions with a + // folder belonging to a DIFFERENT submittal. + baseName := sanitiseFolderName(fmt.Sprintf("%s_%s (TBD) - %s", c.forecast, c.tracking, c.title)) + candidate := filepath.Join(c.root, baseName) + for n := 2; n <= 100; n++ { + if _, statErr := os.Stat(candidate); errors.Is(statErr, os.ErrNotExist) { + break + } else if statErr != nil { + return res, statErr + } + candidate = filepath.Join(c.root, fmt.Sprintf("%s_%d", baseName, n)) + if n == 100 { + return res, fmt.Errorf("convergence: exhausted suffix attempts for %s", baseName) + } + } + if err := os.MkdirAll(candidate, 0o755); err != nil { + return res, fmt.Errorf("mkdir workflow folder: %w", err) + } + target = candidate + res.created = true + } + + // Write .zddc with desired content. Overwrites if present. Workflow + // .zddc carries received_path + acl ONLY — no planned dates (those + // live in the canonical received/.zddc, which the sub-admins + // cannot modify). + zf := zddc.ZddcFile{ + ReceivedPath: c.receivedRel, + CreatedBy: c.creatorEmail, + } + if len(c.acl) > 0 { + zf.ACL = zddc.ACLRules{Permissions: c.acl} + } + if err := zddc.WriteFile(target, zf); err != nil { + return res, fmt.Errorf("write workflow .zddc: %w", err) + } + res.zddcWritten = true + + res.absPath = target + if rel, err := filepath.Rel(c.fsRoot, target); err == nil { + res.relPath = filepath.ToSlash(rel) + } else { + res.relPath = target + } + return res, nil +} + +// findWorkflowFolderByReceivedPath scans root for direct children +// whose .zddc has received_path matching the given relative path. +// Returns the matching absolute path, or "" if none. +func findWorkflowFolderByReceivedPath(root, receivedRel string) (string, error) { + entries, err := os.ReadDir(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", err + } + want := filepath.ToSlash(filepath.Clean(receivedRel)) + for _, e := range entries { + if !e.IsDir() { + continue + } + zddcPath := filepath.Join(root, e.Name(), ".zddc") + zf, perr := zddc.ParseFile(zddcPath) + if perr != nil { + slog.Warn("plan-review: parse workflow .zddc", "path", zddcPath, "err", perr) + continue + } + if zf.ReceivedPath == "" { + continue + } + got := filepath.ToSlash(filepath.Clean(zf.ReceivedPath)) + if got == want { + return filepath.Join(root, e.Name()), nil + } + } + return "", nil +} + +// sanitiseFolderName replaces filesystem-troublesome characters in a +// title with safe substitutes. Conservative — keeps the ZDDC folder +// grammar (the parens and the " - " separator) intact while taming +// arbitrary user input in the title segment. +func sanitiseFolderName(name string) string { + repl := strings.NewReplacer( + "/", "-", + "\\", "-", + ":", "-", + "\x00", "", + ) + return strings.TrimSpace(repl.Replace(name)) +} diff --git a/zddc/internal/handler/planreview_test.go b/zddc/internal/handler/planreview_test.go new file mode 100644 index 0000000..38616d9 --- /dev/null +++ b/zddc/internal/handler/planreview_test.go @@ -0,0 +1,321 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// planReviewSetup writes a tree shaped like a real ZDDC project with +// `archive/Acme/received/Acme-0042/` populated and an admin grant for +// alice@example.com. Returns the cfg, a do() helper that POSTs Plan +// Review requests, and the root path. +func planReviewSetup(t *testing.T) (config.Config, func(target, email string, body []byte) *httptest.ResponseRecorder, string) { + t.Helper() + root := t.TempDir() + + // Root .zddc grants alice subtree-admin everywhere AND sets the + // document_controller role so the cascade's reviewing/+staging/ + // admin grants resolve to her. The role membership also confers + // `c` authority on received/ via the WORM list in the defaults, + // which Plan Review's pre-flight requires. + mustWriteHelper(t, filepath.Join(root, ".zddc"), + "admins:\n - alice@example.com\n"+ + "roles:\n document_controller:\n members: [alice@example.com]\n") + + for _, d := range []string{"Project-1/archive/Acme/received/Acme-0042"} { + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + // Seed a ZDDC-parseable file so the title derives correctly. + mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Foundation.pdf"), + "%PDF-") + zddc.InvalidateCache(root) + + cfg := config.Config{ + Root: root, + EmailHeader: "X-Auth-Request-Email", + MaxWriteBytes: 64 * 1024, + } + + do := func(target, email string, body []byte) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body)) + req.Header.Set(headerOp, opPlanReview) + req.Header.Set("Content-Type", "application/yaml") + ctx := context.WithValue(req.Context(), EmailKey, email) + ctx = context.WithValue(ctx, ElevatedKey, true) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + ServeFileAPI(cfg, rec, req) + return rec + } + return cfg, do, root +} + +func mustWriteHelper(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir parent of %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func planReviewBody() string { + return strings.Join([]string{ + "review_lead: bob@vendor.com", + "approver: carol@example.com", + "plan_review_complete_date: 2026-05-30", + "plan_response_date: 2026-06-15", + }, "\n") + "\n" +} + +// TestPlanReview_FreshConvergence runs Plan Review against a tree with +// no existing workflow folders. Expects both reviewing/ and staging/ +// to be created, each with a .zddc declaring received_path + +// planned_date, and the response to confirm both were created. +func TestPlanReview_FreshConvergence(t *testing.T) { + cfg, do, root := planReviewSetup(t) + + rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com", + []byte(planReviewBody())) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp planReviewResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v; body=%s", err, rec.Body.String()) + } + if resp.Tracking != "Acme-0042" { + t.Errorf("Tracking=%q, want Acme-0042", resp.Tracking) + } + if !resp.Reviewing.Created || !resp.Reviewing.ZddcWritten { + t.Errorf("Reviewing not fully converged: %+v", resp.Reviewing) + } + if !resp.Staging.Created || !resp.Staging.ZddcWritten { + t.Errorf("Staging not fully converged: %+v", resp.Staging) + } + + // Workflow folders: should carry received_path + ACL only. + for _, side := range []struct { + path string + wantDate string + actor string + }{ + {resp.Reviewing.Path, "2026-05-30", "bob@vendor.com"}, + {resp.Staging.Path, "2026-06-15", "carol@example.com"}, + } { + abs := filepath.Join(root, filepath.FromSlash(strings.Trim(side.path, "/"))) + base := filepath.Base(abs) + if !strings.HasPrefix(base, side.wantDate) { + t.Errorf("folder %q does not start with date %q", base, side.wantDate) + } + zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc")) + if err != nil { + t.Fatalf("parse %s/.zddc: %v", abs, err) + } + if zf.ReceivedPath != "archive/Acme/received/Acme-0042" { + t.Errorf("%s: received_path=%q", abs, zf.ReceivedPath) + } + // Workflow .zddc must NOT carry planned dates — those live in + // the canonical received/.zddc and are sealed. + if zf.PlannedReviewDate != "" || zf.PlannedResponseDate != "" { + t.Errorf("%s: workflow .zddc must not carry planned dates", abs) + } + if v, ok := zf.ACL.Permissions[side.actor]; !ok || v != "rwcda" { + t.Errorf("%s: ACL[%s]=%q, want rwcda", abs, side.actor, v) + } + } + + // Canonical received/.zddc: planned dates are sealed here. + zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc")) + if err != nil { + t.Fatalf("parse received .zddc: %v", err) + } + if zfRecv.PlannedReviewDate != "2026-05-30" { + t.Errorf("received planned_review_date=%q", zfRecv.PlannedReviewDate) + } + if zfRecv.PlannedResponseDate != "2026-06-15" { + t.Errorf("received planned_response_date=%q", zfRecv.PlannedResponseDate) + } + // Constrained schema: no ACL, no admins, no roles, no received_path. + if len(zfRecv.ACL.Permissions) != 0 || len(zfRecv.Admins) != 0 || + len(zfRecv.Roles) != 0 || zfRecv.ReceivedPath != "" { + t.Errorf("received .zddc has unexpected content: acl=%v admins=%v roles=%v rp=%q", + zfRecv.ACL.Permissions, zfRecv.Admins, zfRecv.Roles, zfRecv.ReceivedPath) + } + if resp.Title != "Foundation" { + t.Errorf("Title=%q, want Foundation (from received file)", resp.Title) + } + _ = cfg +} + +// TestPlanReview_Idempotent runs Plan Review twice with the same body; +// the second run is a no-op (created=false everywhere) and folder/.zddc +// state is unchanged. +func TestPlanReview_Idempotent(t *testing.T) { + _, do, root := planReviewSetup(t) + + first := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com", + []byte(planReviewBody())) + if first.Code != http.StatusOK { + t.Fatalf("first status=%d; body=%s", first.Code, first.Body.String()) + } + var firstResp planReviewResponse + if err := json.Unmarshal(first.Body.Bytes(), &firstResp); err != nil { + t.Fatalf("decode first: %v", err) + } + + second := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com", + []byte(planReviewBody())) + if second.Code != http.StatusOK { + t.Fatalf("second status=%d; body=%s", second.Code, second.Body.String()) + } + var secondResp planReviewResponse + if err := json.Unmarshal(second.Body.Bytes(), &secondResp); err != nil { + t.Fatalf("decode second: %v", err) + } + + if secondResp.Reviewing.Created || secondResp.Staging.Created { + t.Errorf("second run created=true: %+v", secondResp) + } + if firstResp.Reviewing.Path != secondResp.Reviewing.Path { + t.Errorf("reviewing path drifted: %q vs %q", + firstResp.Reviewing.Path, secondResp.Reviewing.Path) + } + if firstResp.Staging.Path != secondResp.Staging.Path { + t.Errorf("staging path drifted: %q vs %q", + firstResp.Staging.Path, secondResp.Staging.Path) + } + + // Confirm no duplicate folders snuck in. + reviewingRoot := filepath.Join(root, "Project-1", "reviewing") + entries, err := os.ReadDir(reviewingRoot) + if err != nil { + t.Fatalf("read %s: %v", reviewingRoot, err) + } + if len(entries) != 1 { + t.Errorf("reviewing/ has %d entries, want 1", len(entries)) + } +} + +// TestPlanReview_ReceivedZddcIsWriteOnce — re-running Plan Review with +// different planned dates leaves received/.zddc alone (sealed at first +// run). Workflow folder ACLs can still be re-converged on subsequent +// runs. +func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) { + _, do, root := planReviewSetup(t) + + if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com", + []byte(planReviewBody())); rec.Code != http.StatusOK { + t.Fatalf("first POST status=%d; body=%s", rec.Code, rec.Body.String()) + } + + // Second run with a different review_lead AND a different planned + // date. The workflow .zddc should reflect the new actor, but the + // canonical received/.zddc must keep its original dates. + updated := strings.Join([]string{ + "review_lead: dave@vendor.com", + "approver: carol@example.com", + "plan_review_complete_date: 2099-01-01", // attempted but should be ignored + "plan_response_date: 2099-01-15", + }, "\n") + "\n" + if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com", + []byte(updated)); rec.Code != http.StatusOK { + t.Fatalf("second POST status=%d; body=%s", rec.Code, rec.Body.String()) + } + + // received/.zddc unchanged. + zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc")) + if err != nil { + t.Fatalf("parse received: %v", err) + } + if zfRecv.PlannedReviewDate != "2026-05-30" || zfRecv.PlannedResponseDate != "2026-06-15" { + t.Errorf("received dates drifted: review=%q response=%q", + zfRecv.PlannedReviewDate, zfRecv.PlannedResponseDate) + } + + // reviewing/.zddc reflects the new review_lead. + reviewingRoot := filepath.Join(root, "Project-1", "reviewing") + entries, err := os.ReadDir(reviewingRoot) + if err != nil { + t.Fatalf("read %s: %v", reviewingRoot, err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 reviewing folder, got %d", len(entries)) + } + zf, err := zddc.ParseFile(filepath.Join(reviewingRoot, entries[0].Name(), ".zddc")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if _, ok := zf.ACL.Permissions["dave@vendor.com"]; !ok { + t.Errorf("reviewing ACL did not switch to dave: %v", zf.ACL.Permissions) + } +} + +// TestPlanReview_Forbidden — a user without admin authority on the +// workflow roots gets 403 and no folders are created. +func TestPlanReview_Forbidden(t *testing.T) { + _, do, root := planReviewSetup(t) + + rec := do("/Project-1/archive/Acme/received/Acme-0042/", "stranger@vendor.com", + []byte(planReviewBody())) + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "Project-1", "reviewing")); err == nil { + // reviewing/ should not have been materialised. The mkdir + // happens AFTER the ACL check in the handler, so refusal + // guarantees no state change. + entries, _ := os.ReadDir(filepath.Join(root, "Project-1", "reviewing")) + if len(entries) > 0 { + t.Errorf("reviewing/ created despite 403: %d entries", len(entries)) + } + } +} + +// TestCommentResolvedName — counter scope is per-target, plain target +// gets +C1, subsequent targets get sequential +C2/+C3. +func TestCommentResolvedName(t *testing.T) { + root := t.TempDir() + + resolved, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf") + if err != nil { + t.Fatalf("first: %v", err) + } + if resolved != "Acme-0042_A+C1 (RFI) - Foundation.pdf" { + t.Errorf("first=%q, want +C1", resolved) + } + + // Seed a +C1 file; next should be +C2. + if err := os.WriteFile(filepath.Join(root, resolved), []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + resolved2, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf") + if err != nil { + t.Fatalf("second: %v", err) + } + if resolved2 != "Acme-0042_A+C2 (RFI) - Foundation.pdf" { + t.Errorf("second=%q, want +C2", resolved2) + } + + // Different target → independent counter at +C1. + resolvedB, err := zddc.CommentResolvedName(root, "Acme-0042_B (RFI) - Foundation-Spec.pdf") + if err != nil { + t.Fatalf("B: %v", err) + } + if resolvedB != "Acme-0042_B+C1 (RFI) - Foundation-Spec.pdf" { + t.Errorf("B=%q, want +C1", resolvedB) + } +} diff --git a/zddc/internal/handler/reviewinghandler.go b/zddc/internal/handler/reviewinghandler.go deleted file mode 100644 index 02ce49a..0000000 --- a/zddc/internal/handler/reviewinghandler.go +++ /dev/null @@ -1,424 +0,0 @@ -package handler - -import ( - "context" - "encoding/json" - "net/http" - "net/url" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "codeberg.org/VARASYS/ZDDC/zddc/internal/config" - "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" - "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" - "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" -) - -// IsReviewingPath classifies a URL as a reviewing-aggregator path and -// extracts (project, tracking, sidePath). The aggregator is a virtual -// view at: -// -// <project>/reviewing/ → depth 0: pending submittals -// <project>/reviewing/<tracking>/ → depth 1: received/ + staged/ -// <project>/reviewing/<tracking>/<side>/[...] → depth ≥ 2: real folder -// contents (received or -// staged), proxied from -// the canonical archive -// or staging path so the -// user can preview files -// in the browse pane -// without leaving the -// reviewing view. -// -// sidePath at depth 1 is "" (no side selected yet). At depth ≥ 2 it's -// "received[/rest...]" or "staged[/rest...]" — the slash-separated -// remainder after the tracking segment. -// -// Match on "reviewing" is case-insensitive. -func IsReviewingPath(urlPath string) (project, tracking, sidePath string, ok bool) { - parts := strings.Split(strings.Trim(urlPath, "/"), "/") - if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") { - return "", "", "", false - } - switch len(parts) { - case 2: - return parts[0], "", "", true - case 3: - return parts[0], parts[2], "", true - default: - // parts[3] is the side; remainder joins back as the sub-path - // within the real folder. - side := strings.ToLower(parts[3]) - if side != "received" && side != "staged" { - return "", "", "", false - } - rest := strings.Join(parts[4:], "/") - if rest == "" { - return parts[0], parts[2], side, true - } - return parts[0], parts[2], side + "/" + rest, true - } -} - -// pendingSubmittal is one row of the aggregator's view: a submittal in -// archive/<party>/received/ that doesn't yet have a matching entry in -// archive/<party>/issued/, optionally paired with an in-progress -// response folder under staging/. -type pendingSubmittal struct { - tracking string // canonical tracking number, e.g. "123456-ST-SUB-0026" - party string // party folder name, e.g. "Acme" - receivedURL string // /<project>/archive/<party>/received/<folder>/ - stagedURL string // /<project>/staging/<folder>/ or "" if no draft yet - lastModified time.Time // newer of the two folders' mtimes -} - -// computePending walks the project's archive/ and staging/ subtrees to -// build the virtual reviewing-aggregator view. -// -// Algorithm: -// -// 1. Index staging/<folder>/ by tracking number. -// 2. For each party under archive/<party>/: -// a. Index archive/<party>/issued/ by tracking number. -// b. For each archive/<party>/received/<folder>: -// - skip folders that don't parse as transmittal folders. -// - skip if tracking already in issued (response complete). -// - emit a pendingSubmittal pointing at the canonical received -// URL and (if found) the matching staging URL. -// -// ACL: per-party. The caller's email + decider are consulted on the -// archive/<party>/received/ subtree before reading its contents — a -// party the caller can't see at upstream is omitted entirely (no info -// leak via tracking-number listing). -// -// Missing intermediate folders (archive/, party/issued/, staging/) are -// not errors; they just produce empty intermediate sets. This matches -// the lazy-instantiation pattern of the canonical project folders. -func computePending(ctx context.Context, decider policy.Decider, - fsRoot, project, email string) ([]pendingSubmittal, error) { - - projectAbs := filepath.Join(fsRoot, project) - - // Resolve the canonical folder names to whatever case is present - // on disk (deployments may use Archive/ Received/ Issued/ Staging/ - // PascalCase). Empty string means no case variant exists — treated - // as missing (empty contribution to the join). - archiveOnDisk, _ := zddc.ResolveCanonical(projectAbs, "archive") - stagingOnDisk, _ := zddc.ResolveCanonical(projectAbs, "staging") - - // Index staging by tracking → folder name. - stagedByTracking := map[string]string{} - var stagingAbs string - if stagingOnDisk != "" { - stagingAbs = filepath.Join(projectAbs, stagingOnDisk) - if entries, err := os.ReadDir(stagingAbs); err == nil { - for _, e := range entries { - if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { - continue - } - if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok { - stagedByTracking[tracking] = e.Name() - } - } - } - } - - if archiveOnDisk == "" { - return nil, nil - } - archiveAbs := filepath.Join(projectAbs, archiveOnDisk) - parties, err := os.ReadDir(archiveAbs) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var result []pendingSubmittal - for _, p := range parties { - if !p.IsDir() || strings.HasPrefix(p.Name(), ".") { - continue - } - party := p.Name() - partyAbs := filepath.Join(archiveAbs, party) - // Per-party canonical folder resolution (Received/ vs received/). - receivedSeg, _ := zddc.ResolveCanonical(partyAbs, "received") - issuedSeg, _ := zddc.ResolveCanonical(partyAbs, "issued") - if receivedSeg == "" { - continue // party with no received/ at all → nothing to review - } - receivedAbs := filepath.Join(partyAbs, receivedSeg) - - // ACL: skip parties whose received/ subtree the caller can't read. - // Filtering at the party level is cheaper than per-entry and matches - // fs.ListDirectory's omit-denied-subdirs convention. - chain, err := zddc.EffectivePolicy(fsRoot, receivedAbs) - if err != nil { - continue - } - // URL prefix preserves the on-disk casing so links resolve - // directly against the canonicalisation done by the URL - // dispatcher (no additional case-fold round-trip needed). - receivedURLPrefix := "/" + project + "/" + archiveOnDisk + "/" + party + "/" + receivedSeg + "/" - if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, receivedURLPrefix); !allowed { - continue - } - - // Index this party's issued/ trackings (no ACL filter — issued/ - // is WORM-readable to anyone with party access by design, and - // we just need the set membership for matching). - issuedTrackings := map[string]bool{} - if issuedSeg != "" { - if entries, err := os.ReadDir(filepath.Join(partyAbs, issuedSeg)); err == nil { - for _, e := range entries { - if !e.IsDir() { - continue - } - if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok { - issuedTrackings[tracking] = true - } - } - } - } - - receivedEntries, err := os.ReadDir(receivedAbs) - if err != nil { - continue - } - for _, e := range receivedEntries { - if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { - continue - } - _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()) - if !ok { - continue - } - if issuedTrackings[tracking] { - continue // response complete; not pending - } - - info, err := e.Info() - if err != nil { - continue - } - modTime := info.ModTime() - - sub := pendingSubmittal{ - tracking: tracking, - party: party, - receivedURL: receivedURLPrefix + url.PathEscape(e.Name()) + "/", - lastModified: modTime, - } - if stagedFolder, hasDraft := stagedByTracking[tracking]; hasDraft { - sub.stagedURL = "/" + project + "/" + stagingOnDisk + "/" + url.PathEscape(stagedFolder) + "/" - if stagedInfo, err := os.Stat(filepath.Join(stagingAbs, stagedFolder)); err == nil { - if stagedInfo.ModTime().After(modTime) { - sub.lastModified = stagedInfo.ModTime() - } - } - } - result = append(result, sub) - } - } - - sort.Slice(result, func(i, j int) bool { - return result[i].tracking < result[j].tracking - }) - return result, nil -} - -// ServeReviewing emits the aggregator JSON listing for any depth under -// <project>/reviewing/. The HTML branch is handled separately by the -// apps subsystem (browse served at the URL — its markdown editor plugin -// renders responses); only requests that accept JSON reach here. -// -// Depths: -// -// 0 (tracking="") → list pending submittals as virtual -// <tracking>/ folders. -// 1 (tracking, side="") → list received/ + staged/ virtual folders. -// ≥2 (tracking, sidePath) → proxy the listing of the real folder -// under archive/<party>/received/<folder>/... -// or staging/<folder>/... so the user can -// preview files without leaving the -// reviewing view. Folder entries keep -// virtual reviewing/ URLs (navigation -// stays in the aggregator). File entries -// use canonical URLs so byte fetches -// resolve directly against the real path. -func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request, - project, tracking, sidePath string) { - - pending, err := computePending(r.Context(), DeciderFromContext(r), - cfg.Root, project, EmailFromContext(r)) - if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - var entries []listing.FileInfo - switch { - case tracking == "": - // Depth 0: list pending submittals as virtual <tracking>/ folders. - urlPrefix := "/" + project + "/reviewing/" - for _, s := range pending { - entries = append(entries, listing.FileInfo{ - Name: s.tracking + "/", - URL: urlPrefix + url.PathEscape(s.tracking) + "/", - ModTime: s.lastModified, - IsDir: true, - Virtual: true, - }) - } - default: - // Depth ≥1: find the pending entry for this tracking number. - var match *pendingSubmittal - for i := range pending { - if pending[i].tracking == tracking { - match = &pending[i] - break - } - } - if match == nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - if sidePath == "" { - // Depth 1: emit received/ + staged/ virtual folder pointers. - // URLs stay under reviewing/ so navigation into them remains - // in the aggregator (handled by the depth ≥2 branch). - urlPrefix := "/" + project + "/reviewing/" + url.PathEscape(tracking) + "/" - entries = append(entries, listing.FileInfo{ - Name: "received/", - URL: urlPrefix + "received/", - ModTime: match.lastModified, - IsDir: true, - Virtual: true, - }) - if match.stagedURL != "" { - entries = append(entries, listing.FileInfo{ - Name: "staged/", - URL: urlPrefix + "staged/", - ModTime: match.lastModified, - IsDir: true, - Virtual: true, - }) - } - } else { - // Depth ≥2: proxy the real folder's listing. sidePath is - // "received[/rest]" or "staged[/rest]" — split off the - // leading side, append remainder to the canonical base. - side := sidePath - rest := "" - if i := strings.IndexByte(sidePath, '/'); i >= 0 { - side, rest = sidePath[:i], sidePath[i+1:] - } - var realURL string - switch side { - case "received": - realURL = match.receivedURL - case "staged": - if match.stagedURL == "" { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - realURL = match.stagedURL - default: - http.Error(w, "Not Found", http.StatusNotFound) - return - } - if rest != "" { - realURL = strings.TrimSuffix(realURL, "/") + "/" + rest + "/" - } - // Translate the real URL back to a filesystem path so we - // can list it. The URL still encodes percent-escapes; - // PathUnescape them before joining. - realRel := strings.TrimPrefix(realURL, "/") - realRel = strings.TrimSuffix(realRel, "/") - realRelDecoded, decodeErr := url.PathUnescape(realRel) - if decodeErr != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - realAbs := filepath.Join(cfg.Root, filepath.FromSlash(realRelDecoded)) - if !strings.HasPrefix(realAbs, cfg.Root+string(filepath.Separator)) { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - // ACL on the underlying real path; do not proxy what the - // caller can't read directly. - chain, err := zddc.EffectivePolicy(cfg.Root, realAbs) - if err == nil { - if allowed, _ := policy.AllowFromChain(r.Context(), - DeciderFromContext(r), chain, - EmailFromContext(r), realURL); !allowed { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - } - diskEntries, err := os.ReadDir(realAbs) - if err != nil { - if os.IsNotExist(err) { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - // Build the virtual URL prefix (for folder entries) and - // the canonical URL prefix (for file entries). - virtualPrefix := "/" + project + "/reviewing/" + - url.PathEscape(tracking) + "/" + side + "/" - if rest != "" { - virtualPrefix += rest + "/" - } - canonicalPrefix := realURL // already ends with "/" - for _, e := range diskEntries { - name := e.Name() - if strings.HasPrefix(name, ".") { - continue - } - info, err := e.Info() - if err != nil { - continue - } - fi := listing.FileInfo{ - Name: name, - ModTime: info.ModTime(), - } - if e.IsDir() { - fi.Name += "/" - fi.IsDir = true - fi.URL = virtualPrefix + url.PathEscape(name) + "/" - fi.Virtual = true - } else { - fi.Size = info.Size() - // File URL points at the canonical real path so - // fetches (preview, download) hit the right bytes - // directly — no proxying through the aggregator. - fi.URL = canonicalPrefix + url.PathEscape(name) - } - entries = append(entries, fi) - } - sort.Slice(entries, func(i, j int) bool { - // Folders first, then files; both alphabetical. - if entries[i].IsDir != entries[j].IsDir { - return entries[i].IsDir - } - return entries[i].Name < entries[j].Name - }) - } - } - - if entries == nil { - entries = []listing.FileInfo{} - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") // virtual; recompute every time - w.Header().Set("X-ZDDC-Source", "reviewing-aggregator") - _ = json.NewEncoder(w).Encode(entries) -} diff --git a/zddc/internal/handler/reviewinghandler_test.go b/zddc/internal/handler/reviewinghandler_test.go deleted file mode 100644 index 95e1e38..0000000 --- a/zddc/internal/handler/reviewinghandler_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - - "codeberg.org/VARASYS/ZDDC/zddc/internal/config" - "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" - "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" -) - -func TestIsReviewingPath(t *testing.T) { - cases := []struct { - path string - wantOK bool - wantProj string - wantTracking string - wantSide string - }{ - {"/Project/reviewing/", true, "Project", "", ""}, - {"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001", ""}, - // Case-insensitive on the literal "reviewing" segment. - {"/Project/Reviewing/", true, "Project", "", ""}, - {"/Project/REVIEWING/x/", true, "Project", "x", ""}, - // No trailing slash: still classified (caller decides redirect). - {"/Project/reviewing", true, "Project", "", ""}, - {"/Project/reviewing/123/", true, "Project", "123", ""}, - // Depth 2+: side present. - {"/Project/reviewing/123/received/", true, "Project", "123", "received"}, - {"/Project/reviewing/123/staged/", true, "Project", "123", "staged"}, - {"/Project/reviewing/123/received/sub/", true, "Project", "123", "received/sub"}, - // Unknown side at depth 2 is rejected. - {"/Project/reviewing/123/issued/", false, "", "", ""}, - // Non-canonical / wrong shape. - {"/Project/", false, "", "", ""}, - {"/", false, "", "", ""}, - {"/Project/working/", false, "", "", ""}, - } - for _, tc := range cases { - gotProj, gotTracking, gotSide, gotOK := IsReviewingPath(tc.path) - if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking || gotSide != tc.wantSide { - t.Errorf("IsReviewingPath(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)", - tc.path, gotProj, gotTracking, gotSide, gotOK, tc.wantProj, tc.wantTracking, tc.wantSide, tc.wantOK) - } - } -} - -// Test setup: build a synthetic project tree with two parties, one -// pending submittal each. Verify the aggregator returns: -// - depth 0: 2 virtual <tracking>/ entries, sorted, both with -// URLs under /<project>/reviewing/ -// - depth 1: received/ + staged/ entries with canonical URLs -func TestServeReviewing(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, ".zddc"), - "acl:\n permissions:\n \"*\": rwcda\n") - - // Two parties under archive/, each with a pending submittal. - // Acme: submitted but no response staged or issued yet. - // Beta: submitted, response staged but not yet issued. - pAcmeReceived := filepath.Join(root, "Project", "archive", "Acme", "received", - "2025-10-31_001-AB-SUB-0001 (IFR) - Pending acme review") - pBetaReceived := filepath.Join(root, "Project", "archive", "Beta", "received", - "2025-11-01_002-AB-SUB-0007 (IFR) - Pending beta review") - pBetaStaged := filepath.Join(root, "Project", "staging", - "2025-11-15_002-AB-SUB-0007 (RSC) - Beta response draft") - for _, p := range []string{pAcmeReceived, pBetaReceived, pBetaStaged} { - mustMkdir(t, p) - } - // And a third party (Gamma) where the submittal has BEEN issued — - // should NOT appear in the pending list. - pGammaReceived := filepath.Join(root, "Project", "archive", "Gamma", "received", - "2025-09-01_003-CD-SUB-0099 (IFR) - Already responded") - pGammaIssued := filepath.Join(root, "Project", "archive", "Gamma", "issued", - "2025-09-15_003-CD-SUB-0099 (RSC) - The response we sent") - mustMkdir(t, pGammaReceived) - mustMkdir(t, pGammaIssued) - - zddc.InvalidateCache(root) - - cfg := config.Config{ - Root: root, - EmailHeader: "X-Auth-Request-Email", - } - - t.Run("depth-0 lists pending submittals only", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/", nil) - req.Header.Set("Accept", "application/json") - req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) - rec := httptest.NewRecorder() - ServeReviewing(cfg, rec, req, "Project", "", "") - - if rec.Code != http.StatusOK { - t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) - } - var got []listing.FileInfo - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) - } - if len(got) != 2 { - t.Fatalf("got %d entries, want 2 (Acme + Beta pending; Gamma issued); body=%s", - len(got), rec.Body.String()) - } - // Sorted by tracking number → 001-* before 002-*. - if got[0].Name != "001-AB-SUB-0001/" { - t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "001-AB-SUB-0001/") - } - if got[1].Name != "002-AB-SUB-0007/" { - t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "002-AB-SUB-0007/") - } - for i, e := range got { - if !e.IsDir || !e.Virtual { - t.Errorf("entries[%d] IsDir=%v Virtual=%v, want both true", i, e.IsDir, e.Virtual) - } - // Per-submittal URL stays under reviewing/ (the user can - // drill into the per-submittal received/+staged/ view). - if e.URL != "/Project/reviewing/"+got[i].Name[:len(got[i].Name)-1]+"/" { - t.Errorf("entries[%d].URL=%q, want under /Project/reviewing/", i, e.URL) - } - } - }) - - t.Run("depth-1 with staged draft → received/ + staged/", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/002-AB-SUB-0007/", nil) - req.Header.Set("Accept", "application/json") - req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) - rec := httptest.NewRecorder() - ServeReviewing(cfg, rec, req, "Project", "002-AB-SUB-0007", "") - - if rec.Code != http.StatusOK { - t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) - } - var got []listing.FileInfo - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if len(got) != 2 { - t.Fatalf("got %d entries, want 2 (received/ + staged/); body=%s", - len(got), rec.Body.String()) - } - if got[0].Name != "received/" { - t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "received/") - } - // Virtual URL — stays under reviewing/ so depth-2 navigation - // returns to the aggregator (which lists the real folder's - // contents with canonical file URLs). - if want := "/Project/reviewing/002-AB-SUB-0007/received/"; got[0].URL != want { - t.Errorf("received URL=%q, want %q", got[0].URL, want) - } - if got[1].Name != "staged/" { - t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "staged/") - } - if want := "/Project/reviewing/002-AB-SUB-0007/staged/"; got[1].URL != want { - t.Errorf("staged URL=%q, want %q", got[1].URL, want) - } - }) - - t.Run("depth-1 with no staged draft → received/ only", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/001-AB-SUB-0001/", nil) - req.Header.Set("Accept", "application/json") - req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) - rec := httptest.NewRecorder() - ServeReviewing(cfg, rec, req, "Project", "001-AB-SUB-0001", "") - - if rec.Code != http.StatusOK { - t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) - } - var got []listing.FileInfo - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if len(got) != 1 || got[0].Name != "received/" { - t.Fatalf("got %+v, want [received/] only (no draft)", got) - } - }) - - t.Run("depth-1 unknown tracking → 404", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/999-ZZ-SUB-9999/", nil) - req.Header.Set("Accept", "application/json") - req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) - rec := httptest.NewRecorder() - ServeReviewing(cfg, rec, req, "Project", "999-ZZ-SUB-9999", "") - if rec.Code != http.StatusNotFound { - t.Errorf("status=%d, want 404", rec.Code) - } - }) - - t.Run("missing archive/ entirely → empty depth-0 listing", func(t *testing.T) { - // Fresh project state: no archive/ subtree at all. - bareRoot := t.TempDir() - mustWrite(t, filepath.Join(bareRoot, ".zddc"), - "acl:\n permissions:\n \"*\": rwcda\n") - mustMkdir(t, filepath.Join(bareRoot, "Fresh")) - zddc.InvalidateCache(bareRoot) - bareCfg := config.Config{Root: bareRoot, EmailHeader: "X-Auth-Request-Email"} - - req := httptest.NewRequest(http.MethodGet, "/Fresh/reviewing/", nil) - req.Header.Set("Accept", "application/json") - req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) - rec := httptest.NewRecorder() - ServeReviewing(bareCfg, rec, req, "Fresh", "", "") - if rec.Code != http.StatusOK { - t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) - } - body := rec.Body.String() - // Empty array, not "null". - if body == "null" || body == "null\n" { - t.Errorf("body=%q, want []; nil-slice encoded as null", body) - } - }) -} - -// startsWith — local helper. mustMkdir / mustWrite live in -// formhandler_test.go and are reused here. -func startsWith(s, prefix string) bool { - return len(s) >= len(prefix) && s[:len(prefix)] == prefix -} diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 462a8a4..472d4f7 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -92,9 +92,9 @@ available_tools: [archive, browse, landing] # Every ZDDC project lives at a top-level directory. Under it the # convention is four canonical folders: archive (formal record), # working (in-progress workspace), staging (outbound prep), reviewing -# (purely virtual aggregator). Under archive/<party>/ the convention -# is four more: mdl (deliverables list), incoming (counterparty drop -# zone), received (immutable submittals), issued (immutable responses). +# (Plan-Review-managed draft workspaces). Under archive/<party>/ the +# convention is four more: mdl (deliverables list), incoming (counterparty +# drop zone), received (immutable submittals), issued (immutable responses). # # All of this is expressed via the recursive paths: schema. None of # the directories need to exist on disk — the cascade walker resolves @@ -119,6 +119,14 @@ paths: permissions: project_team: r document_controller: rw + # Plan Review composite endpoint: the doc controller right-clicks + # archive/<party>/received/<tracking>/ in the browse app and gets + # a "Plan Review" item that scaffolds workflow folders under the + # paths below. Both keys required; omitting the block disables + # the menu item for this subtree. + on_plan_review: + reviewing_root: reviewing/ + staging_root: staging/ paths: archive: default_tool: archive @@ -236,6 +244,13 @@ paths: reviewing: default_tool: browse available_tools: [browse] - # reviewing/ is purely virtual — the aggregator handler - # synthesises listings from received/ ↔ staging/ ↔ issued/. - virtual: true + # reviewing/ is the doc-controller's draft-workspace area. The + # "Plan Review" composite endpoint (see on_plan_review at project + # level) scaffolds a physical folder here for each submittal + # under review, with a .zddc carrying received_path back to the + # canonical submittal in received/. Subtree-admin so the doc + # controller can author per-folder .zddc files (originator ACL, + # planned_date). + auto_own: true + drop_target: true + admins: [document_controller] diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 9a555c3..10a7f30 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -92,6 +92,18 @@ type Role struct { Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"` } +// OnPlanReviewConfig is the cascade block enabling the doc-controller +// "Plan Review" composite endpoint. ReviewingRoot and StagingRoot are +// paths relative to the master root (e.g. "<project>/reviewing/" or +// "archive/<project>/reviewing/"). Both must be non-empty for the +// feature to enable; either being empty disables Plan Review for this +// subtree (the right-click menu item hides client-side via +// /.profile/access exposure of this config). +type OnPlanReviewConfig struct { + ReviewingRoot string `yaml:"reviewing_root,omitempty" json:"reviewing_root,omitempty"` + StagingRoot string `yaml:"staging_root,omitempty" json:"staging_root,omitempty"` +} + // ConvertMetadata supplies per-project template variables for the // server-side MD→{docx,html,pdf} conversion endpoint. The handler // resolves the effective set by walking the .zddc cascade leaf→root @@ -308,6 +320,45 @@ type ZddcFile struct { // apps-subsystem auto-route. AvailableTools []string `yaml:"available_tools,omitempty" json:"available_tools,omitempty"` + // ReceivedPath links a workflow folder (under reviewing/ or staging/) + // back to its canonical submittal in received/. Populated by the + // Plan Review composite endpoint at scaffold time and travels with + // the folder through the reviewing/ → staging/ → issued/ lifecycle. + // The path is relative to the master root (e.g. "archive/Acme/ + // received/Acme-0042"), so it survives the workflow folder being + // moved between parents. + // + // When this field is non-empty, the listing handler synthesises + // a virtual `received/` child whose contents come from this path, + // and serveFilePut rewrites writes through that virtual prefix to + // `<workflow>/<base>+C<n><suffix>` comment files in the workflow + // folder itself (the canonical submittal is WORM). + ReceivedPath string `yaml:"received_path,omitempty" json:"received_path,omitempty"` + + // PlannedReviewDate / PlannedResponseDate are the doc-controller's + // committed dates for this submittal's review-completion and + // response-issuance, set by Plan Review and stored on the + // CANONICAL submittal's .zddc (received/<tracking>/.zddc) — NOT on + // the workflow folders' .zddc files. The sub-admins (review lead, + // approver) manage ACLs in their respective workflow folders but + // cannot edit these dates, since the cascade does not grant them + // admin authority over received/. + // + // Both fields are ISO date strings (YYYY-MM-DD). Distinct from the + // workflow folder names' date prefixes, which are *forecast* dates + // — mutable via direct folder rename as estimates shift. Folder + // name = live forecast; .zddc planned date = original commitment. + // Comparing the issued/ folder's actual date against these planned + // dates after publish yields planned-vs-actual on-time analysis. + PlannedReviewDate string `yaml:"planned_review_date,omitempty" json:"planned_review_date,omitempty"` + PlannedResponseDate string `yaml:"planned_response_date,omitempty" json:"planned_response_date,omitempty"` + + // OnPlanReview is the cascade-declared configuration for the + // "Plan Review" composite endpoint. Empty (nil) means Plan Review + // is not enabled at this subtree — the browse client hides the + // menu item. Set in an ancestor .zddc to enable. + OnPlanReview *OnPlanReviewConfig `yaml:"on_plan_review,omitempty" json:"on_plan_review,omitempty"` + // Paths declares virtual sub-directory rules without those // directories needing to exist on disk. Each key is a single path // segment — either a literal name or `*` (matches any segment). diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index b407e68..0a2d3d5 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -224,6 +224,60 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string { return out } +// CanonicalFolderAt returns the canonical-folder name for THIS specific +// directory — one of "archive", "working", "staging", "reviewing", +// "incoming", "received", "issued", "mdl" — or "" if the path is not +// at a canonical-folder slot. +// +// Detection is structural against the canonical project layout declared +// in defaults.zddc.yaml: top-level <project>/{archive,working,staging, +// reviewing} and the second-level archive/<party>/{mdl,incoming, +// received,issued}. Operators don't rename these slots (the cascade +// keys them by literal name); a custom layout that does is on its own. +// +// Used by the browse SPA to scope-gate context-menu actions (Accept, +// Stage/Unstage, Create Transmittal folder) without re-implementing the +// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header. +func CanonicalFolderAt(fsRoot, dirPath string) string { + segs := resolvePathSegments(fsRoot, dirPath) + // <project>/<folder> + if len(segs) == 2 { + switch segs[1] { + case "archive", "working", "staging", "reviewing": + return segs[1] + } + return "" + } + // <project>/archive/<party>/<folder> + if len(segs) == 4 && segs[1] == "archive" { + switch segs[3] { + case "incoming", "received", "issued", "mdl": + return segs[3] + } + } + return "" +} + +// OnPlanReviewAt returns the cascade-resolved Plan Review configuration +// for dirPath, or nil if no level (on-disk, virtual via Paths, or +// embedded) declares one. Walks chain.Levels from leaf toward root, +// returning the first non-nil OnPlanReview. The block has to be present +// somewhere in the ancestry for the "Plan Review" menu item to surface +// in the browse client and for the composite endpoint to know where to +// scaffold workflow folders. +func OnPlanReviewAt(fsRoot, dirPath string) *OnPlanReviewConfig { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return nil + } + for i := len(chain.Levels) - 1; i >= 0; i-- { + if cfg := chain.Levels[i].OnPlanReview; cfg != nil { + return cfg + } + } + return chain.Embedded.OnPlanReview +} + // leafLevel returns the deepest (most-specific) ZddcFile in chain. // Caller's responsibility to check len(chain.Levels) > 0 — but // returns ZddcFile{} on empty for ergonomic chaining. @@ -248,6 +302,10 @@ func isZeroZddcFile(zf ZddcFile) bool { zf.DropTarget != nil || zf.Inherit != nil { return false } + if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" || + zf.PlannedResponseDate != "" || zf.OnPlanReview != nil { + return false + } if len(zf.AvailableTools) > 0 { return false } diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index c2770e0..deb7929 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -71,6 +71,41 @@ func TestDirToolAt(t *testing.T) { } } +// TestCanonicalFolderAt — structural detection of the canonical +// project-layout slots that the browse SPA scope-gates context-menu +// actions against. Top-level <project>/<folder> and second-level +// <project>/archive/<party>/<folder>; everything else returns "". +func TestCanonicalFolderAt(t *testing.T) { + resetCache() + root := t.TempDir() + cases := []struct { + path string + want string + }{ + {filepath.Join(root, "Project-X", "archive"), "archive"}, + {filepath.Join(root, "Project-X", "working"), "working"}, + {filepath.Join(root, "Project-X", "staging"), "staging"}, + {filepath.Join(root, "Project-X", "reviewing"), "reviewing"}, + {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "incoming"}, + {filepath.Join(root, "Project-X", "archive", "Acme", "received"), "received"}, + {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "issued"}, + {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "mdl"}, + {root, ""}, + {filepath.Join(root, "Project-X"), ""}, + {filepath.Join(root, "Project-X", "working", "alice@example.com"), ""}, + {filepath.Join(root, "Project-X", "archive", "Acme"), ""}, + {filepath.Join(root, "Project-X", "archive", "Acme", "incoming", "2026-05-15_Acme-0042 (RFI) - Foundation"), ""}, + {filepath.Join(root, "Project-X", "random", "dir"), ""}, + } + for _, tc := range cases { + got := CanonicalFolderAt(root, tc.path) + if got != tc.want { + t.Errorf("CanonicalFolderAt(%q) = %q, want %q", + tc.path[len(root):], got, tc.want) + } + } +} + // TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for // working/incoming/staging (per the convention) and false elsewhere. func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) { @@ -97,9 +132,9 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) { } } -// TestVirtualAt_FromEmbeddedConvention — reviewing/ and mdl/ are -// declared virtual; everything else (including working/staging/ -// incoming) materialises on disk. +// TestVirtualAt_FromEmbeddedConvention — mdl/ is declared virtual; +// everything else (including reviewing/, which is now Plan-Review- +// managed with physical workflow folders) materialises on disk. func TestVirtualAt_FromEmbeddedConvention(t *testing.T) { resetCache() root := t.TempDir() @@ -107,8 +142,8 @@ func TestVirtualAt_FromEmbeddedConvention(t *testing.T) { path string want bool }{ - {filepath.Join(root, "Project-X", "reviewing"), true}, {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true}, + {filepath.Join(root, "Project-X", "reviewing"), false}, {filepath.Join(root, "Project-X", "working"), false}, {filepath.Join(root, "Project-X", "staging"), false}, {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false}, diff --git a/zddc/internal/zddc/virtualreceived.go b/zddc/internal/zddc/virtualreceived.go new file mode 100644 index 0000000..a982817 --- /dev/null +++ b/zddc/internal/zddc/virtualreceived.go @@ -0,0 +1,222 @@ +package zddc + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Virtual `received/` window — the doc-controller's Plan Review composite +// endpoint scaffolds physical folders under <reviewing_root> and +// <staging_root>, each carrying a .zddc whose `received_path:` points back +// at the canonical archive/<party>/received/<tracking>/. When a workflow +// folder is listed, the server injects a synthetic `received/` child that +// shows the canonical submittal's contents in context. +// +// Three behaviours rely on this: +// +// GET <workflow>/received/ → list the canonical received/<tracking>/ +// GET <workflow>/received/<file> → serve canonical bytes (read passthrough) +// PUT <workflow>/received/<file> → rewrite to <workflow>/<base>+C<n><suffix> +// (the canonical record is WORM) +// +// Helpers below give the file API and listing handlers a single point of +// detection so the routing stays declarative. + +// IsWorkflowFolder reports whether dirPath has a .zddc with a non-empty +// ReceivedPath — i.e. it's a Plan-Review-scaffolded reviewing/ or staging/ +// folder. +func IsWorkflowFolder(dirPath string) bool { + rp := WorkflowReceivedPath(dirPath) + return rp != "" +} + +// WorkflowReceivedPath returns the .zddc.received_path for dirPath, or +// empty if the file doesn't exist or doesn't declare one. The path is +// as-stored (typically relative to the master root). +func WorkflowReceivedPath(dirPath string) string { + zf, err := ParseFile(filepath.Join(dirPath, ".zddc")) + if err != nil { + return "" + } + return zf.ReceivedPath +} + +// VirtualReceivedResolution captures the result of mapping a request URL +// onto either a workflow folder's synthetic `received/` child or a +// canonical path under it. All fields are populated only when Resolved +// is true. +type VirtualReceivedResolution struct { + Resolved bool + WorkflowAbs string // absolute path of the workflow folder + WorkflowURL string // server-relative URL of the workflow folder, slash-terminated (e.g. "/Project/reviewing/2026-05-30_X (TBD) - …/") + ReceivedAbs string // absolute path of the canonical received target (or canonical+suffix when the URL drills into a file) + ReceivedURL string // server-relative URL of the canonical received target + SuffixURL string // URL suffix after the `/received/` segment, slash-prefixed when non-empty (e.g. "" or "Acme-0042_A (RFI) - Foundation.pdf") + IsRoot bool // true iff the URL targets `<workflow>/received/` itself (no suffix) +} + +// virtualReceivedRE matches any URL that traverses a `received` segment +// not at the canonical archive/<party>/received/<tracking>/ position. +// The match is loose; the resolver verifies the parent .zddc carries a +// ReceivedPath before returning Resolved=true. +// +// Captures: +// 1: workflow URL prefix (including trailing slash before "received") +// 2: suffix after "received/" (may be empty) +var virtualReceivedRE = regexp.MustCompile(`^(/.+/)received(?:/(.*))?$`) + +// ResolveVirtualReceived inspects urlPath and returns a populated +// resolution iff: +// +// - the URL contains a `received/` segment whose parent on disk is a +// workflow folder (.zddc.received_path is set), AND +// - the URL is NOT the canonical archive/<party>/received/<tracking>/[...] +// form (handlers there go through normal routing). +// +// The canonical form is detected by checking the .zddc.received_path of +// the parent — if the parent's path matches what received_path points at, +// that's the canonical record, not a synthetic mapping. +// +// On a non-match, Resolved=false and other fields are zero. +// +// urlPath is the server-relative URL with one leading slash. trailingSlash +// indicates whether the original URL ended with a slash (meaning a directory +// listing was requested vs a file). +func ResolveVirtualReceived(fsRoot, urlPath string) VirtualReceivedResolution { + var out VirtualReceivedResolution + if urlPath == "" || urlPath[0] != '/' { + return out + } + trimmed := strings.TrimSuffix(urlPath, "/") + m := virtualReceivedRE.FindStringSubmatch(trimmed) + if m == nil { + return out + } + workflowURL := m[1] + suffix := m[2] + + // Translate workflow URL → workflow absolute path. + workflowRel := strings.TrimPrefix(strings.TrimSuffix(workflowURL, "/"), "/") + workflowAbs := filepath.Join(fsRoot, filepath.FromSlash(workflowRel)) + if !strings.HasPrefix(workflowAbs, fsRoot+string(filepath.Separator)) && workflowAbs != fsRoot { + return out + } + + // Workflow folder must carry a .zddc.received_path. + rp := WorkflowReceivedPath(workflowAbs) + if rp == "" { + return out + } + + // Guard: if workflowAbs itself happens to be the canonical received + // folder for some weird cascade, don't loop on it. The canonical + // record never has a .zddc declaring its own received_path, so this + // can only happen with operator misconfiguration; bail out. + receivedRel := filepath.ToSlash(filepath.Clean(rp)) + if filepath.ToSlash(strings.TrimPrefix(workflowAbs, fsRoot+string(filepath.Separator))) == receivedRel { + return out + } + + receivedAbs := filepath.Join(fsRoot, filepath.FromSlash(receivedRel)) + receivedURL := "/" + receivedRel + "/" + + if suffix != "" { + // File or sub-path drill-in. Append to both abs and URL. + receivedAbs = filepath.Join(receivedAbs, filepath.FromSlash(suffix)) + receivedURL = "/" + receivedRel + "/" + suffix + if strings.HasSuffix(urlPath, "/") { + receivedURL += "/" + } + } + + out.Resolved = true + out.WorkflowAbs = workflowAbs + out.WorkflowURL = workflowURL + out.ReceivedAbs = receivedAbs + out.ReceivedURL = receivedURL + out.SuffixURL = suffix + out.IsRoot = suffix == "" + return out +} + +// commentFilenameRE captures the canonical filename shape with an +// optional +Cn modifier already on the revision, so we can compute the +// next n for a target. +// +// Captures: +// +// 1: tracking, e.g. "Acme-0042" +// 2: revision base (without +C<n>), e.g. "A" +// 3: existing +C<n> number (may be empty if no modifier), e.g. "1" +// 4: rest of the filename, e.g. " (RFI) - Foundation.pdf" +var commentFilenameRE = regexp.MustCompile(`^([^_]+)_([^+\s()]+)(?:\+C(\d+))?(\s*\([^)]+\)\s*-\s*.+)$`) + +// CommentResolvedName computes the next +Cn comment filename for the +// given target name inside workflowAbs. The target name is the file the +// user dropped onto (e.g. "Acme-0042_A (RFI) - Foundation.pdf"); the +// returned name has a `+C<n>` modifier on the revision token. n starts +// at 1 and increments past any existing comments for the same target. +// +// If targetName doesn't match the canonical ZDDC filename pattern, an +// error is returned — comment uploads are only meaningful against +// parseable submittals. +func CommentResolvedName(workflowAbs, targetName string) (string, error) { + m := commentFilenameRE.FindStringSubmatch(targetName) + if m == nil { + return "", errors.New("target filename does not match the ZDDC pattern") + } + tracking := m[1] + baseRev := m[2] + rest := m[4] + + // Scan workflowAbs for siblings matching <tracking>_<baseRev>+C<n><rest>. + entries, err := os.ReadDir(workflowAbs) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + maxN := 0 + prefix := tracking + "_" + baseRev + "+C" + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasPrefix(name, prefix) { + continue + } + mm := commentFilenameRE.FindStringSubmatch(name) + if mm == nil || mm[1] != tracking || mm[2] != baseRev || mm[3] == "" { + continue + } + var n int + for _, ch := range mm[3] { + if ch < '0' || ch > '9' { + n = 0 + break + } + n = n*10 + int(ch-'0') + } + if n > maxN { + maxN = n + } + } + return tracking + "_" + baseRev + "+C" + itoa(maxN+1) + rest, nil +} + +// itoa is a tiny base-10 stringifier — small enough not to pull strconv. +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [10]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index b398d40..f8a1234 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -91,6 +91,18 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.Virtual != nil { out.Virtual = top.Virtual } + if top.ReceivedPath != "" { + out.ReceivedPath = top.ReceivedPath + } + if top.PlannedReviewDate != "" { + out.PlannedReviewDate = top.PlannedReviewDate + } + if top.PlannedResponseDate != "" { + out.PlannedResponseDate = top.PlannedResponseDate + } + if top.OnPlanReview != nil { + out.OnPlanReview = top.OnPlanReview + } out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools) out.Admins = mergeStringSlice(out.Admins, top.Admins)