ZDDC/zddc/internal/zddc/virtualreceived.go
ZDDC 59b5550872 refactor: nest lifecycle slots per-party + add virtual top-level aggregators
May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:

  ssr/mdl/rsk           tables rollups across parties with a
                        synthesised $party source-party column
  working/staging/      browse folder-nav listings of parties with
  reviewing             non-empty content in the slot; per-party
                        URLs 302-redirect to archive/<party>/<slot>/

Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.

Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.

document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:57:45 -05:00

222 lines
7.8 KiB
Go

package zddc
import (
"errors"
"os"
"path/filepath"
"regexp"
"strings"
)
// Virtual `received/` window — the doc-controller's Plan Review composite
// endpoint scaffolds physical folders under archive/<party>/reviewing/ and
// archive/<party>/staging/, 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/archive/Acme/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:])
}