ZDDC/zddc/internal/handler/accepthandler.go
ZDDC 690d185dc2 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>
2026-05-15 16:08:04 -05:00

274 lines
11 KiB
Go

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)
}