ZDDC/zddc/internal/handler/virtualviewhandler.go
ZDDC 73e34bed5e feat: per-party RSK + project-level SSR/MDL/RSK rollup tables
Adds the risk register as a sibling of MDL under archive/<party>/, and
three project-level virtual aggregations at <project>/{ssr,mdl,rsk}:

  - SSR aggregates archive/<party>/ssr.yaml; "+ Add row" materializes a
    new party folder (mkdir + auto-own .zddc + ssr.yaml). Renames go
    through X-ZDDC-Op: ssr-rename, which os.Rename's the party
    directory so every row inside follows. Party name doubles as the
    folder name (no opaque IDs) and is path-derived on read.

  - MDL/RSK rollups list every deliverable / every risk across all
    parties with a derived `party` column; "+ Add row" is suppressed
    because party affiliation is ambiguous in the aggregate view.

All four virtual roots are declared `virtual: true` in
defaults.zddc.yaml. Spec/form bytes come from six new embedded
defaults (default-rsk.*, default-ssr.*, default-project-{mdl,rsk}.*)
served via a generalized IsDefaultSpec/IsDefaultSpecAbs that replaces
the MDL-only recognizer. Listing synthesis lives in fs/tree.go;
ACL on each synthetic row evaluates against the canonical
archive/<party>/ chain so non-owners see rows read-only. PUT/DELETE
through virtual URLs rewrite to canonical paths in fileapi.go via
sibling-shape blocks that don't touch the ACL gate. SSR row DELETE
returns 405 (delete the party folder via the archive view).

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

101 lines
3.2 KiB
Go

// Package handler — virtualviewhandler.go: GET dispatch for SSR row +
// MDL/RSK rollup row URLs.
//
// These URLs live in the project-level virtual folders (<project>/ssr,
// <project>/mdl, <project>/rsk) and rewrite to canonical files inside
// <project>/archive/<party>/. The bytes returned to the client are
// augmented with a single path-derived field that the canonical file
// doesn't carry:
//
// - SSR rows get `name: <party>` so the table renderer has a column
// to sort on and the form edit pre-fills the party name.
// - MDL / RSK rollup rows get `party: <party>` so the rollup table
// can show which package each row came from.
//
// Both fields are stripped before write-back (SSR via serveFormCreateSSR
// strip; MDL/RSK rollup writes go through the generic serveFormUpdate,
// where the path-derived `party:` is rejected by `additionalProperties:
// false` in the underlying schema — so the client must strip it on
// submit, which the tables/form JS already does for path-derived
// fields).
//
// Listings: see fs/tree.go.
package handler
import (
"net/http"
"os"
"strconv"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
// ServeVirtualViewRow serves a GET (or HEAD) for one of the virtual
// row URLs. Caller is expected to have already evaluated ACL against
// vv.PartyArchive's chain.
//
// For SSR rows: returns the canonical archive/<party>/ssr.yaml bytes
// with `name: <party>` injected. If no canonical file exists yet,
// returns `name: <party>\n` (an otherwise-empty row) — the SSR view
// shows every party folder whether or not metadata has been written.
//
// For MDL / RSK rollup rows: returns the canonical bytes with
// `party: <party>` injected. If the canonical file doesn't exist
// (shouldn't happen — the listing only surfaces real files) returns
// 404.
func ServeVirtualViewRow(w http.ResponseWriter, r *http.Request, vv zddc.VirtualViewResolution) {
if !vv.Resolved || !vv.Kind.IsRowKind() {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
raw, err := os.ReadFile(vv.CanonicalAbs)
if err != nil {
if !os.IsNotExist(err) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// File doesn't exist yet.
if vv.Kind != zddc.VirtualViewSSRRow {
http.NotFound(w, r)
return
}
raw = nil
}
var data map[string]any
if len(raw) > 0 {
if err := yaml.Unmarshal(raw, &data); err != nil {
http.Error(w, "parse canonical yaml: "+err.Error(), http.StatusInternalServerError)
return
}
}
if data == nil {
data = make(map[string]any)
}
switch vv.Kind {
case zddc.VirtualViewSSRRow:
data["name"] = vv.Party
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
data["party"] = vv.Party
}
out, err := yaml.Marshal(data)
if err != nil {
http.Error(w, "marshal virtual row: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-ZDDC-Source", "virtual-view-row")
w.Header().Set("X-ZDDC-Resolved-Path", vv.CanonicalURL)
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(out)
}