feat: reviewing/ lifecycle — Plan Review endpoint, virtual received window, browse context-menu workflows
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 /<project>/archive/<party>/received/<tracking>/ 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 <workflow>/received/, the server resolves through the canonical archive/<party>/received/<tracking>/ via the workflow's .zddc.received_path. Writes get rewritten to <workflow>/<base>+C<n><suffix> 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/<party>/incoming/ → validates ZDDC folder + filename conformance, atomic-renames the folder to archive/<party>/received/<tracking>/ (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/<email>/ 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) <noreply@anthropic.com>
This commit is contained in:
parent
b4c0327f63
commit
690d185dc2
25 changed files with 3016 additions and 682 deletions
|
|
@ -69,6 +69,10 @@ concat_files \
|
||||||
"js/grid.js" \
|
"js/grid.js" \
|
||||||
"js/upload.js" \
|
"js/upload.js" \
|
||||||
"js/download.js" \
|
"js/download.js" \
|
||||||
|
"js/plan-review.js" \
|
||||||
|
"js/accept-transmittal.js" \
|
||||||
|
"js/stage.js" \
|
||||||
|
"js/create-transmittal.js" \
|
||||||
"js/events.js" \
|
"js/events.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
|
||||||
312
browse/js/accept-transmittal.js
Normal file
312
browse/js/accept-transmittal.js
Normal file
|
|
@ -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/<their-party>/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 = '<div style="margin:0.5rem 0;padding:0.5rem 0.75rem;background:#fff3cd;border-left:3px solid #d39e00;font-size:0.85rem;">'
|
||||||
|
+ '<strong>Non-conforming files detected:</strong><ul style="margin:0.25rem 0 0 1rem;padding:0;">'
|
||||||
|
+ initial.violations.map(function (v) { return '<li>' + escapeHtml(v) + '</li>'; }).join('')
|
||||||
|
+ '</ul><p style="margin:0.4rem 0 0 0;">Cancel and contact the sender to correct these before re-uploading.</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var planReviewFieldsHtml =
|
||||||
|
'<div id="acc-pr-fields" style="display:none;margin-top:0.6rem;padding:0.5rem 0.75rem;background:rgba(0,0,0,0.03);border-radius:4px;">' +
|
||||||
|
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.4rem 0.75rem;align-items:center;font-size:0.9rem;">' +
|
||||||
|
'<label for="acc-review-lead">Review lead</label>' +
|
||||||
|
'<input id="acc-review-lead" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
|
||||||
|
'<label for="acc-review-date">Plan review complete date</label>' +
|
||||||
|
'<input id="acc-review-date" type="date">' +
|
||||||
|
'<label for="acc-approver">Approver</label>' +
|
||||||
|
'<input id="acc-approver" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
|
||||||
|
'<label for="acc-response-date">Plan response date</label>' +
|
||||||
|
'<input id="acc-response-date" type="date">' +
|
||||||
|
'<datalist id="acc-people"></datalist>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
box.innerHTML =
|
||||||
|
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Accept Transmittal — ' + escapeHtml(initial.tracking) + '</h2>' +
|
||||||
|
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
||||||
|
'This will file <strong>' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</strong> from ' +
|
||||||
|
'<code>' + escapeHtml(initial.folder) + '</code> into the immutable received archive at ' +
|
||||||
|
'<code>archive/' + escapeHtml(initial.party) + '/received/' + escapeHtml(initial.tracking) + '/</code>. ' +
|
||||||
|
'Once filed, only document-control can add new files there; nothing can be edited or deleted.' +
|
||||||
|
'</p>' +
|
||||||
|
violationsHtml +
|
||||||
|
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
|
||||||
|
'<label for="acc-received-date">Received date</label>' +
|
||||||
|
'<input id="acc-received-date" type="date" required>' +
|
||||||
|
'</div>' +
|
||||||
|
'<label style="display:flex;align-items:center;gap:0.4rem;margin-top:0.8rem;font-size:0.9rem;">' +
|
||||||
|
'<input type="checkbox" id="acc-setup-pr">' +
|
||||||
|
'<span>Set up Plan Review now — scaffold the reviewing/ and staging/ folders for the response</span>' +
|
||||||
|
'</label>' +
|
||||||
|
planReviewFieldsHtml +
|
||||||
|
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||||
|
'<button type="button" id="acc-cancel">Cancel</button>' +
|
||||||
|
'<button type="button" id="acc-submit" class="btn-primary"' +
|
||||||
|
(initial.violations && initial.violations.length ? ' disabled' : '') + '>Accept</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
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/<party>/incoming/<folder>/.
|
||||||
|
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
|
||||||
|
};
|
||||||
|
})();
|
||||||
146
browse/js/create-transmittal.js
Normal file
146
browse/js/create-transmittal.js
Normal file
|
|
@ -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 =
|
||||||
|
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Create Transmittal folder</h2>' +
|
||||||
|
'<p style="margin:0 0 0.6rem 0;font-size:0.85rem;color:#666;">' +
|
||||||
|
"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.' +
|
||||||
|
'</p>' +
|
||||||
|
'<label for="ct-name" style="font-size:0.9rem;">Folder name (ZDDC convention)</label>' +
|
||||||
|
'<input id="ct-name" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" ' +
|
||||||
|
'placeholder="YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT" value="' + escapeHtml(isoDateToday() + '_') + '">' +
|
||||||
|
'<div id="ct-feedback" style="font-size:0.8rem;color:#888;margin-top:0.2rem;min-height:1.1em;"></div>' +
|
||||||
|
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||||
|
'<button type="button" id="ct-cancel">Cancel</button>' +
|
||||||
|
'<button type="button" id="ct-submit" class="btn-primary" disabled>Create</button>' +
|
||||||
|
'</div>';
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
|
|
@ -368,6 +368,21 @@
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when this node is a file viewed through the synthetic
|
||||||
|
// <workflow>/received/ window — the URL has a `received/` segment
|
||||||
|
// that's NOT preceded by `archive/<party>/` (the canonical record
|
||||||
|
// form). A drop here is a review-comment intent: server rewrites to
|
||||||
|
// <workflow>/<base>+C<n><suffix>.
|
||||||
|
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) {
|
function dragHasFiles(e) {
|
||||||
if (!e.dataTransfer || !e.dataTransfer.types) return false;
|
if (!e.dataTransfer || !e.dataTransfer.types) return false;
|
||||||
var types = e.dataTransfer.types;
|
var types = e.dataTransfer.types;
|
||||||
|
|
@ -422,6 +437,28 @@
|
||||||
var id = parseInt(row.dataset.id, 10);
|
var id = parseInt(row.dataset.id, 10);
|
||||||
var node = state.nodes.get(id);
|
var node = state.nodes.get(id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
// Comment-upload short-circuit: drop on a file that lives
|
||||||
|
// under the virtual <workflow>/received/ window is a "comment
|
||||||
|
// on this file" intent. PUT to the target's URL — the server
|
||||||
|
// rewrites to <workflow>/<base>+C<n><suffix> 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<n> 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);
|
var dest = targetDirForNode(node);
|
||||||
if (!dest) return;
|
if (!dest) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -776,6 +813,62 @@
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|
||||||
|
// ── Plan Review (received/<tracking>/ 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 ──
|
// ── View ──
|
||||||
{ label: 'Sort by', items: SORT_BY_ITEMS },
|
{ label: 'Sort by', items: SORT_BY_ITEMS },
|
||||||
{ label: 'Show hidden files',
|
{ label: 'Show hidden files',
|
||||||
|
|
@ -803,6 +896,17 @@
|
||||||
disabled: !serverMode,
|
disabled: !serverMode,
|
||||||
action: function () { createInDir(state.currentPath || '/', 'markdown'); }
|
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 },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: 'Refresh',
|
label: 'Refresh',
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,20 @@
|
||||||
// without re-implementing the cascade client-side.
|
// without re-implementing the cascade client-side.
|
||||||
window.app.state.scopeDefaultTool =
|
window.app.state.scopeDefaultTool =
|
||||||
(resp.headers.get('X-ZDDC-Default-Tool') || '').toLowerCase();
|
(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/<tracking>/
|
||||||
|
// 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) {
|
if (resp.status === 404) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
276
browse/js/plan-review.js
Normal file
276
browse/js/plan-review.js
Normal file
|
|
@ -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/<party>/received/<tracking>/ 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/<tracking>/.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/<tracking>/. 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/<tracking>/ 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 =
|
||||||
|
'<h2 style="margin:0 0 0.75rem 0;font-size:1.1rem;">Plan Review — ' + escapeHtml(initial.tracking) + '</h2>' +
|
||||||
|
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
|
||||||
|
'<label for="pr-review-lead">Review lead</label>' +
|
||||||
|
'<input id="pr-review-lead" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
|
||||||
|
'<label for="pr-review-date">Plan review complete date</label>' +
|
||||||
|
'<input id="pr-review-date" type="date" required>' +
|
||||||
|
'<label for="pr-approver">Approver</label>' +
|
||||||
|
'<input id="pr-approver" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
|
||||||
|
'<label for="pr-response-date">Plan response date</label>' +
|
||||||
|
'<input id="pr-response-date" type="date" required>' +
|
||||||
|
'<datalist id="pr-people-list"></datalist>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p style="margin:0.75rem 0 0 0;font-size:0.8rem;color:#666;">Planned dates seal at first submission — they become part of the canonical record (received/<tracking>/.zddc) and the WORM zone prevents further edits. Subsequent Plan Reviews can swap the review lead or approver without changing the dates.</p>' +
|
||||||
|
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||||
|
'<button type="button" id="pr-cancel">Cancel</button>' +
|
||||||
|
'<button type="button" id="pr-submit" class="btn-primary">Plan Review</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
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/<party>/received/<tracking>/
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
329
browse/js/stage.js
Normal file
329
browse/js/stage.js
Normal file
|
|
@ -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/<transmittal>/ back to the user's
|
||||||
|
// working/<email>/ 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
|
||||||
|
// /<project>/working/<…>. Unstageable if it lives under
|
||||||
|
// /<project>/staging/<transmittal>/<…>. 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/<transmittal-folder>/<file> — 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 '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;">' +
|
||||||
|
'<input type="radio" name="stage-target" value="' + escapeHtml(name) + '">' +
|
||||||
|
'<span style="font-family:var(--code,monospace);">' + escapeHtml(name) + '</span>' +
|
||||||
|
'</label>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
box.innerHTML =
|
||||||
|
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Stage ' +
|
||||||
|
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' to…</h2>' +
|
||||||
|
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
||||||
|
'Pick the transmittal folder in <code>staging/</code> these files should join. ' +
|
||||||
|
'You can move them back to <code>working/</code> later if they need correction.' +
|
||||||
|
'</p>' +
|
||||||
|
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
|
||||||
|
(folderList || '<em style="color:#888;">No existing transmittal folders in staging/.</em>') +
|
||||||
|
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
|
||||||
|
'<input type="radio" name="stage-target" value="__new__">' +
|
||||||
|
'<span><strong>New transmittal folder…</strong></span>' +
|
||||||
|
'</label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="stage-newname-row" style="display:none;font-size:0.9rem;">' +
|
||||||
|
'<label for="stage-newname">Folder name (ZDDC convention)</label><br>' +
|
||||||
|
'<input id="stage-newname" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" ' +
|
||||||
|
'placeholder="YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT">' +
|
||||||
|
'<div id="stage-newname-feedback" style="font-size:0.8rem;color:#888;margin-top:0.2rem;"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||||
|
'<button type="button" id="stage-cancel">Cancel</button>' +
|
||||||
|
'<button type="button" id="stage-submit" class="btn-primary">Stage</button>' +
|
||||||
|
'</div>';
|
||||||
|
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 =
|
||||||
|
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Unstage ' +
|
||||||
|
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</h2>' +
|
||||||
|
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
||||||
|
'Move these files back into your drafting workspace under <code>working/</code> ' +
|
||||||
|
'so they can be corrected. Stage them again when ready.' +
|
||||||
|
'</p>' +
|
||||||
|
'<label for="unstage-target" style="font-size:0.9rem;">Destination folder</label>' +
|
||||||
|
'<input id="unstage-target" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" value="' +
|
||||||
|
escapeHtml(initial.defaultTarget) + '">' +
|
||||||
|
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||||
|
'<button type="button" id="unstage-cancel">Cancel</button>' +
|
||||||
|
'<button type="button" id="unstage-submit" class="btn-primary">Unstage</button>' +
|
||||||
|
'</div>';
|
||||||
|
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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -238,6 +238,71 @@
|
||||||
return { ok: ok, fail: fail };
|
return { ok: ok, fail: fail };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comment upload: PUT each dropped file's bytes to the target URL.
|
||||||
|
// The server detects the virtual <workflow>/received/ context and
|
||||||
|
// rewrites the destination to <workflow>/<base>+C<n><suffix>, 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/<file>`
|
||||||
|
// 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 ────────────────────────────────────────────────
|
// ── Create-new helpers ────────────────────────────────────────────────
|
||||||
// Both go through the same server endpoints used by upload: PUT
|
// Both go through the same server endpoints used by upload: PUT
|
||||||
// for files (with an empty/template body) and POST + X-ZDDC-Op:
|
// for files (with an empty/template body) and POST + X-ZDDC-Op:
|
||||||
|
|
@ -475,6 +540,7 @@
|
||||||
window.app.modules.upload = {
|
window.app.modules.upload = {
|
||||||
currentScopeAllows: currentScopeAllows,
|
currentScopeAllows: currentScopeAllows,
|
||||||
uploadToDir: uploadToDir,
|
uploadToDir: uploadToDir,
|
||||||
|
uploadCommentToTarget: uploadCommentToTarget,
|
||||||
makeDir: makeDir,
|
makeDir: makeDir,
|
||||||
makeFile: makeFile,
|
makeFile: makeFile,
|
||||||
removeNode: removeNode,
|
removeNode: removeNode,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||||
'REC',
|
'REC',
|
||||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||||
|
'TBD',
|
||||||
];
|
];
|
||||||
|
|
||||||
var STATUS_SET = {};
|
var STATUS_SET = {};
|
||||||
|
|
|
||||||
|
|
@ -1079,37 +1079,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reviewing aggregator. <project>/reviewing/[<tracking>/] is
|
// reviewing/ is no longer a virtual aggregator — it's a normal
|
||||||
// a virtual view. The shape rule mirrors the other canonical
|
// directory under each project, populated by the Plan Review
|
||||||
// folders (slash → browse, no-slash → default tool):
|
// composite endpoint with physical workflow folders. Falls
|
||||||
// - JSON request, any depth → aggregator listing (handler.ServeReviewing)
|
// through to the canonical-folder block below.
|
||||||
// - HTML, no slash → browse (default tool, via DefaultAppAt;
|
//
|
||||||
// browse hosts the markdown editor plugin)
|
// Virtual received/ window. <workflow>/received/[...] is a
|
||||||
// - HTML, with slash → browse.html (via ServeDirectory).
|
// synthetic view onto the canonical received/<tracking>/
|
||||||
// browse fetches JSON which routes back
|
// declared by the workflow folder's .zddc.received_path.
|
||||||
// through here to ServeReviewing.
|
// ResolveVirtualReceived validates the parent .zddc; on a
|
||||||
// Depth-3 no-slash (reviewing/<tracking>) 302s to the slash form.
|
// match, route through the normal directory/file handlers,
|
||||||
// Depth-2 no-slash (reviewing) falls through to the canonical-
|
// which swap the read source to the canonical based on the
|
||||||
// folder block below where DefaultAppAt routes to browse.
|
// URL (ListDirectory and ServeFile via the absolute path).
|
||||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok {
|
if vr := zddc.ResolveVirtualReceived(cfg.Root, urlPath); vr.Resolved {
|
||||||
if !strings.HasSuffix(urlPath, "/") {
|
if strings.HasSuffix(urlPath, "/") {
|
||||||
if tracking != "" {
|
handler.ServeDirectory(cfg, appsSrv, w, r)
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// HTML trailing-slash falls through to canonical-folder
|
// File read — ACL-check against the canonical
|
||||||
// block → ServeDirectory → embedded browse.html.
|
// 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
|
// Cascade-declared paths: the .zddc cascade (embedded
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,17 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
return nil, os.ErrNotExist
|
return nil, os.ErrNotExist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Virtual received/ window: when the URL points at <workflow>/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/<tracking>/ path. Entry URLs stay
|
||||||
|
// rooted at baseURL so the browse client keeps the workflow context —
|
||||||
|
// drag-drop onto an entry here PUTs to <workflow>/received/<file>,
|
||||||
|
// which serveFilePut intercepts and rewrites to <workflow>/<base>+C<n><suffix>.
|
||||||
|
if vr := zddc.ResolveVirtualReceived(fsRoot, strings.TrimSuffix(baseURL, "/")); vr.Resolved && vr.IsRoot {
|
||||||
|
absDir = vr.ReceivedAbs
|
||||||
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(absDir)
|
entries, err := os.ReadDir(absDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Empty-listing fallback for cascade-declared paths. A fresh
|
// 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.
|
// to real ones.
|
||||||
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
|
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/<tracking>/ 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
|
// Surface a virtual `.zddc` entry when the on-disk file doesn't
|
||||||
// exist. The /<dir>/.zddc URL always serves SOMETHING — real
|
// exist. The /<dir>/.zddc URL always serves SOMETHING — real
|
||||||
// bytes if present, a synthetic placeholder body otherwise (see
|
// bytes if present, a synthetic placeholder body otherwise (see
|
||||||
|
|
|
||||||
274
zddc/internal/handler/accepthandler.go
Normal file
274
zddc/internal/handler/accepthandler.go
Normal file
|
|
@ -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/<party>/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/<transmittal>/ (move source).
|
||||||
|
// document_controller has rwcd on incoming/ via the cascade defaults.
|
||||||
|
// - ActionCreate on received/<tracking>/ (move destination, WORM zone).
|
||||||
|
// document_controller has `cr` here via worm: [document_controller].
|
||||||
|
//
|
||||||
|
// Operation:
|
||||||
|
//
|
||||||
|
// 1. Parse URL — must be a direct child of archive/<party>/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/<tracking>/ does NOT exist → os.Rename the whole
|
||||||
|
// folder (atomic, fast).
|
||||||
|
// If received/<tracking>/ 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/<tracking>/ 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/<tracking>/.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 /<project>/archive/<party>/incoming/<transmittal>/.
|
||||||
|
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 /<project>/archive/<party>/incoming/<transmittal>/", 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_<tracking> (<status>) - <title>)", 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)
|
||||||
|
}
|
||||||
193
zddc/internal/handler/accepthandler_test.go
Normal file
193
zddc/internal/handler/accepthandler_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -147,6 +147,22 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
|
||||||
if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" {
|
if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" {
|
||||||
w.Header().Set("X-ZDDC-Default-Tool", 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") {
|
if strings.Contains(accept, "application/json") {
|
||||||
// Content-hash ETag on the listing payload. Re-fetched on every
|
// Content-hash ETag on the listing payload. Re-fetched on every
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,49 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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
|
// Resolve canonical-folder casing on the way in (no side effects): a
|
||||||
// request for /Project/working/foo.md when the on-disk folder is
|
// request for /Project/working/foo.md when the on-disk folder is
|
||||||
// Working/ should land in Working/, not create a duplicate sibling.
|
// 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)
|
serveFileMove(cfg, w, r)
|
||||||
case opMkdir:
|
case opMkdir:
|
||||||
serveFileMkdir(cfg, w, r)
|
serveFileMkdir(cfg, w, r)
|
||||||
|
case opPlanReview:
|
||||||
|
servePlanReview(cfg, w, r)
|
||||||
|
case opAcceptTransmittal:
|
||||||
|
serveAcceptTransmittal(cfg, w, r)
|
||||||
case "":
|
case "":
|
||||||
http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
|
http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
449
zddc/internal/handler/planreview.go
Normal file
449
zddc/internal/handler/planreview.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
321
zddc/internal/handler/planreview_test.go
Normal file
321
zddc/internal/handler/planreview_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -92,9 +92,9 @@ available_tools: [archive, browse, landing]
|
||||||
# Every ZDDC project lives at a top-level directory. Under it the
|
# Every ZDDC project lives at a top-level directory. Under it the
|
||||||
# convention is four canonical folders: archive (formal record),
|
# convention is four canonical folders: archive (formal record),
|
||||||
# working (in-progress workspace), staging (outbound prep), reviewing
|
# working (in-progress workspace), staging (outbound prep), reviewing
|
||||||
# (purely virtual aggregator). Under archive/<party>/ the convention
|
# (Plan-Review-managed draft workspaces). Under archive/<party>/ the
|
||||||
# is four more: mdl (deliverables list), incoming (counterparty drop
|
# convention is four more: mdl (deliverables list), incoming (counterparty
|
||||||
# zone), received (immutable submittals), issued (immutable responses).
|
# drop zone), received (immutable submittals), issued (immutable responses).
|
||||||
#
|
#
|
||||||
# All of this is expressed via the recursive paths: schema. None of
|
# All of this is expressed via the recursive paths: schema. None of
|
||||||
# the directories need to exist on disk — the cascade walker resolves
|
# the directories need to exist on disk — the cascade walker resolves
|
||||||
|
|
@ -119,6 +119,14 @@ paths:
|
||||||
permissions:
|
permissions:
|
||||||
project_team: r
|
project_team: r
|
||||||
document_controller: rw
|
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:
|
paths:
|
||||||
archive:
|
archive:
|
||||||
default_tool: archive
|
default_tool: archive
|
||||||
|
|
@ -236,6 +244,13 @@ paths:
|
||||||
reviewing:
|
reviewing:
|
||||||
default_tool: browse
|
default_tool: browse
|
||||||
available_tools: [browse]
|
available_tools: [browse]
|
||||||
# reviewing/ is purely virtual — the aggregator handler
|
# reviewing/ is the doc-controller's draft-workspace area. The
|
||||||
# synthesises listings from received/ ↔ staging/ ↔ issued/.
|
# "Plan Review" composite endpoint (see on_plan_review at project
|
||||||
virtual: true
|
# 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]
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,18 @@ type Role struct {
|
||||||
Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"`
|
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
|
// ConvertMetadata supplies per-project template variables for the
|
||||||
// server-side MD→{docx,html,pdf} conversion endpoint. The handler
|
// server-side MD→{docx,html,pdf} conversion endpoint. The handler
|
||||||
// resolves the effective set by walking the .zddc cascade leaf→root
|
// resolves the effective set by walking the .zddc cascade leaf→root
|
||||||
|
|
@ -308,6 +320,45 @@ type ZddcFile struct {
|
||||||
// apps-subsystem auto-route.
|
// apps-subsystem auto-route.
|
||||||
AvailableTools []string `yaml:"available_tools,omitempty" json:"available_tools,omitempty"`
|
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
|
// Paths declares virtual sub-directory rules without those
|
||||||
// directories needing to exist on disk. Each key is a single path
|
// directories needing to exist on disk. Each key is a single path
|
||||||
// segment — either a literal name or `*` (matches any segment).
|
// segment — either a literal name or `*` (matches any segment).
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,60 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
|
||||||
return out
|
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.
|
// leafLevel returns the deepest (most-specific) ZddcFile in chain.
|
||||||
// Caller's responsibility to check len(chain.Levels) > 0 — but
|
// Caller's responsibility to check len(chain.Levels) > 0 — but
|
||||||
// returns ZddcFile{} on empty for ergonomic chaining.
|
// returns ZddcFile{} on empty for ergonomic chaining.
|
||||||
|
|
@ -248,6 +302,10 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
||||||
zf.DropTarget != nil || zf.Inherit != nil {
|
zf.DropTarget != nil || zf.Inherit != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" ||
|
||||||
|
zf.PlannedResponseDate != "" || zf.OnPlanReview != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if len(zf.AvailableTools) > 0 {
|
if len(zf.AvailableTools) > 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
|
||||||
// working/incoming/staging (per the convention) and false elsewhere.
|
// working/incoming/staging (per the convention) and false elsewhere.
|
||||||
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
|
|
@ -97,9 +132,9 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVirtualAt_FromEmbeddedConvention — reviewing/ and mdl/ are
|
// TestVirtualAt_FromEmbeddedConvention — mdl/ is declared virtual;
|
||||||
// declared virtual; everything else (including working/staging/
|
// everything else (including reviewing/, which is now Plan-Review-
|
||||||
// incoming) materialises on disk.
|
// managed with physical workflow folders) materialises on disk.
|
||||||
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
resetCache()
|
resetCache()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
@ -107,8 +142,8 @@ func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
path string
|
path string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{filepath.Join(root, "Project-X", "reviewing"), true},
|
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), 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", "working"), false},
|
||||||
{filepath.Join(root, "Project-X", "staging"), false},
|
{filepath.Join(root, "Project-X", "staging"), false},
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
||||||
|
|
|
||||||
222
zddc/internal/zddc/virtualreceived.go
Normal file
222
zddc/internal/zddc/virtualreceived.go
Normal file
|
|
@ -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:])
|
||||||
|
}
|
||||||
|
|
@ -91,6 +91,18 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.Virtual != nil {
|
if top.Virtual != nil {
|
||||||
out.Virtual = top.Virtual
|
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.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
|
||||||
|
|
||||||
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue