feat(tables): rollup Add Row routes via the party column

The project-level MDL/RSK rollup specs lose `addable: false` and gain
a sibling form schema (default-project-{mdl,rsk}.form.yaml) that
makes `party` a required field. + Add row on the rollup view is now
live: the user types the party name in the Package column, the
server reads `party` from the body, validates that
<project>/archive/<party>/ exists on disk, strips the field, and
writes the row into archive/<party>/<slot>/<date>-<email>.yaml. The
response Location is the synthetic <project>/<slot>/<party>__<file>.yaml
URL so the rollup table client swaps the draft URL cleanly.

Wrong party = 422 with a clear error pointing at the SSR view as the
place to create the folder first. No auto-creation here — the rollup
is for filing deliverables/risks against existing packages, not for
spinning up new ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-19 08:14:37 -05:00
parent cef7188a77
commit f3d334a221
9 changed files with 383 additions and 19 deletions

View file

@ -0,0 +1,91 @@
# Default project-rollup MDL row schema, served by zddc-server when
# no operator-supplied form.yaml exists at <project>/mdl/.
#
# Identical to the per-party MDL schema (default-mdl.form.yaml)
# except for one extra required field: `party`. That field is the
# routing key — the server reads it on POST <project>/mdl/form.html,
# finds the matching <project>/archive/<party>/ folder, and writes
# the row inside its mdl/ subfolder. The `party` value is stripped
# from the YAML on write (folder name IS the identity); on read the
# dispatcher injects it back so the rollup table can show the
# Package column.
#
# To customize: drop your own form.yaml at <project>/mdl/form.yaml.
# Keep the `party` field shape unless you also customize the rollup
# create handler — the server's routing depends on it.
title: Deliverable (project rollup)
description: One deliverable across all parties. The first field (Package) routes the row to the matching archive/<party>/mdl/ folder; the rest mirrors the per-party MDL schema.
schema:
type: object
required: [party, originator, project, discipline, type, sequence, title]
additionalProperties: false
properties:
party:
type: string
title: Package (party folder)
description: Routing key — must match an existing <project>/archive/<party>/ folder. Typical naming = MasterFormat 4-digit code + C|P + sequence digit (e.g. 0330C1).
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
minLength: 1
originator:
type: string
title: Originator
description: Organizational unit responsible for this deliverable (e.g. ACME).
minLength: 1
phase:
type: string
title: Phase
description: Optional project phase code (e.g. ECI, EPC).
project:
type: string
title: Project
description: Project identifier, or your corporate placeholder for non-project deliverables.
minLength: 1
area:
type: string
title: Area
description: Optional area / budget code (e.g. B02).
discipline:
type: string
title: Discipline
description: Engineering or functional group code (EL, ME, CV, PM, ...).
minLength: 1
type:
type: string
title: Document type
description: Document category code within the discipline (SPC, DWG, RPT, ...).
minLength: 1
sequence:
type: string
title: Sequence
description: Zero-padded integer (0001, 0042, 2623). Stored as a string so leading zeros survive YAML.
minLength: 1
suffix:
type: string
title: Suffix
description: Optional structural-part suffix.
title:
type: string
title: Deliverable title
minLength: 1
plannedRevision:
type: string
title: Planned revision
plannedDate:
type: string
title: Planned date
format: date
status:
type: string
title: Current status
enum: [DFT, IFR, IFA, IFC, AFC, AB]
owner:
type: string
title: Owner
notes:
type: string
title: Notes
ui:
notes:
ui:widget: textarea

View file

@ -8,15 +8,14 @@
# column is derived from the row's source folder (path-injected by # column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML). # the server, not stored in the YAML).
# #
# + Add row is suppressed in this view because the party affiliation # + Add row IS enabled here: the `party` column doubles as the
# would be ambiguous — add deliverables at the per-party path # routing key — the server reads the submitted `party` field, finds
# (<project>/archive/<party>/mdl/) and they'll appear here on next # the matching <project>/archive/<party>/ folder, and writes the row
# load. # inside its mdl/ subfolder. The party folder must already exist
# (create it via the SSR view).
title: Project Deliverables (all parties) title: Project Deliverables (all parties)
description: Every deliverable across all parties under archive/. Click a row to edit; add rows at the per-party MDL view. description: Every deliverable across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/mdl/ folder.
addable: false
columns: columns:
- field: party - field: party

View file

@ -0,0 +1,82 @@
# Default project-rollup RSK row schema, served by zddc-server when
# no operator-supplied form.yaml exists at <project>/rsk/.
#
# Identical to the per-party RSK schema (default-rsk.form.yaml)
# except for one extra required field: `party`. The server reads it
# on POST <project>/rsk/form.html and routes the row to the matching
# <project>/archive/<party>/rsk/ folder. The `party` value is
# stripped from the YAML on write (folder name IS the identity); on
# read the dispatcher injects it back.
#
# To customize: drop your own form.yaml at <project>/rsk/form.yaml.
# Keep the `party` field shape unless you also customize the rollup
# create handler.
title: Risk (project rollup)
description: One risk across all parties. The first field (Package) routes the row to the matching archive/<party>/rsk/ folder; the rest mirrors the per-party RSK schema.
schema:
type: object
required: [party, id, title]
additionalProperties: false
properties:
party:
type: string
title: Package (party folder)
description: Routing key — must match an existing <project>/archive/<party>/ folder.
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
minLength: 1
id:
type: string
title: ID
description: Stable identifier, e.g. R-001.
minLength: 1
title:
type: string
title: Risk
minLength: 1
category:
type: string
title: Category
description:
type: string
title: Description
likelihood:
type: integer
title: Likelihood
minimum: 1
maximum: 5
impact:
type: integer
title: Impact
minimum: 1
maximum: 5
severity:
type: integer
title: Severity
minimum: 1
maximum: 25
mitigation:
type: string
title: Mitigation
owner:
type: string
title: Owner
status:
type: string
title: Status
enum: [open, mitigated, accepted, closed]
dueDate:
type: string
title: Due date
format: date
notes:
type: string
title: Notes
ui:
description:
ui:widget: textarea
mitigation:
ui:widget: textarea
notes:
ui:widget: textarea

View file

@ -7,15 +7,14 @@
# column is derived from the row's source folder (path-injected by # column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML). # the server, not stored in the YAML).
# #
# + Add row is suppressed in this view because the party affiliation # + Add row IS enabled here: the `party` column doubles as the
# would be ambiguous — add risks at the per-party path # routing key — the server reads the submitted `party` field, finds
# (<project>/archive/<party>/rsk/) and they'll appear here on next # the matching <project>/archive/<party>/ folder, and writes the row
# load. # inside its rsk/ subfolder. The party folder must already exist
# (create it via the SSR view).
title: Project Risk Register (all parties) title: Project Risk Register (all parties)
description: Every risk across all parties under archive/. Click a row to edit; add rows at the per-party RSK view. description: Every risk across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/rsk/ folder.
addable: false
columns: columns:
- field: party - field: party

View file

@ -89,9 +89,12 @@ type FormRequest struct {
// SubmitURL is the URL the form should POST back to (the server-injected // SubmitURL is the URL the form should POST back to (the server-injected
// "submit to my own URL" value). // "submit to my own URL" value).
SubmitURL string SubmitURL string
// Project carries the project name for create-via-ssr requests. Empty // Project carries the project name for create-via-ssr /
// for all other kinds. // create-via-rollup requests. Empty for all other kinds.
Project string Project string
// Slot carries the slot name ("mdl" or "rsk") for create-via-rollup
// requests. Empty for all other kinds.
Slot string
} }
// RecognizeFormRequest classifies r as a form-system request, or returns nil // RecognizeFormRequest classifies r as a form-system request, or returns nil
@ -130,6 +133,26 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
} }
} }
// Project-rollup MDL / RSK create: /<project>/(mdl|rsk)/form.html
// reads a `party` field from the body and routes the new row to
// <project>/archive/<party>/<slot>/. Recognized before the generic
// /<dir>/form.html branch so a virtual rollup URL doesn't get
// misrouted as an in-dir create.
if project, slot, ok := zddc.IsRollupCreateURL(urlPath); ok {
kind := "render-empty"
if method == http.MethodPost {
kind = "create-via-rollup"
}
specAbs := filepath.Join(fsRoot, project, slot, "form.yaml")
return &FormRequest{
Kind: kind,
SpecPath: specAbs,
SubmitURL: urlPath,
Project: project,
Slot: slot,
}
}
underlying := strings.TrimSuffix(urlPath, ".html") underlying := strings.TrimSuffix(urlPath, ".html")
// specEligible accepts a spec path that exists on disk OR matches // specEligible accepts a spec path that exists on disk OR matches
@ -249,6 +272,8 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
serveFormUpdate(cfg, req, w, r) serveFormUpdate(cfg, req, w, r)
case "create-via-ssr": case "create-via-ssr":
serveFormCreateSSR(cfg, req, w, r) serveFormCreateSSR(cfg, req, w, r)
case "create-via-rollup":
serveFormCreateRollup(cfg, req, w, r)
default: default:
http.Error(w, "unknown form request kind", http.StatusInternalServerError) http.Error(w, "unknown form request kind", http.StatusInternalServerError)
} }

View file

@ -30,6 +30,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema" "codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
@ -179,6 +180,133 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(yamlBytes), nil) auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(yamlBytes), nil)
} }
// serveFormCreateRollup adds a row to a project-level MDL or RSK
// rollup view by writing it inside the per-party folder named by the
// submitted `party` field.
//
// Wire-form: POST /<project>/(mdl|rsk)/form.html
//
// Content-Type: application/yaml | application/json
// body: { party: "<name>", ...row fields... }
//
// The rollup form schema (default-project-{mdl,rsk}.form.yaml) makes
// `party` a required field; the rollup view's `Package` column maps
// to it. The party folder must already exist — create it via the
// SSR view first. ACL gate: ActionCreate at
// <project>/archive/<party>/<slot>/, same chain the generic
// serveFormCreate would gate against if the user were on the
// per-party path directly.
//
// On success: 201 + Location: /<project>/<slot>/<party>__<filename>.yaml,
// the virtual row URL that the rollup table client uses to address
// the new row.
func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if req.Project == "" || (req.Slot != "mdl" && req.Slot != "rsk") {
http.Error(w, "internal: rollup create missing project/slot", http.StatusInternalServerError)
return
}
data, err := decodeRequestData(r)
if err != nil {
http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
return
}
spec, err := loadFormSpec(cfg.Root, req.SpecPath)
if err != nil {
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return
}
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
writeValidationErrors(w, errs)
return
}
dataMap, ok := data.(map[string]interface{})
if !ok {
http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest)
return
}
partyRaw, _ := dataMap["party"].(string)
party := strings.TrimSpace(partyRaw)
if !zddc.ValidPartyName(party) {
writeValidationErrors(w, []jsonschema.Error{{
Path: "/party",
Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`,
}})
return
}
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", party)
if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
if info, err := os.Stat(partyAbs); err != nil || !info.IsDir() {
writeValidationErrors(w, []jsonschema.Error{{
Path: "/party",
Message: "party folder does not exist — create it via the SSR view first",
}})
return
}
slotAbs := filepath.Join(partyAbs, req.Slot)
slotURL := "/" + req.Project + "/archive/" + party + "/" + req.Slot + "/"
rowDirURL := slotURL // The slot folder where the new row lands.
_ = rowDirURL // kept for clarity; ACL chain is gated below.
// ACL gate: create at <project>/archive/<party>/<slot>/. authorizeAction
// walks up to the closest existing ancestor (typically <party>/), where
// the auto-own .zddc grants the party owner rwcd.
if !authorizeAction(cfg, w, r, slotAbs, slotURL, policy.ActionCreate) {
return
}
// Strip the routing key from the data before write — the folder
// name IS the identity and the per-party MDL/RSK schemas forbid
// `additionalProperties` other than the listed ones.
delete(dataMap, "party")
if err := os.MkdirAll(slotAbs, 0o755); err != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
dateStr := time.Now().UTC().Format("2006-01-02")
emailSan := sanitizeEmail(email)
base := dateStr + "-" + emailSan
target, fname, ok := pickAvailableFilename(slotAbs, base, ".yaml")
if !ok {
http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
return
}
yamlBytes, err := yaml.Marshal(dataMap)
if err != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return
}
if err := zddc.WriteAtomic(target, yamlBytes); err != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), err)
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
return
}
rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname
w.Header().Set("Location", rowURL)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "rollup-create")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL)))
auditFile(r, "rollup-create", rowURL, http.StatusCreated, len(yamlBytes), nil)
}
// serveSSRRename renames a party folder by rewriting an SSR row URL. // serveSSRRename renames a party folder by rewriting an SSR row URL.
// //
// Wire-form: POST /<project>/ssr/<old>.yaml // Wire-form: POST /<project>/ssr/<old>.yaml

View file

@ -65,6 +65,12 @@ var embeddedDefaultProjectMdlTable []byte
//go:embed default-project-rsk.table.yaml //go:embed default-project-rsk.table.yaml
var embeddedDefaultProjectRskTable []byte var embeddedDefaultProjectRskTable []byte
//go:embed default-project-mdl.form.yaml
var embeddedDefaultProjectMdlForm []byte
//go:embed default-project-rsk.form.yaml
var embeddedDefaultProjectRskForm []byte
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes. // DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
// Used by callers that need the canonical spec without going through // Used by callers that need the canonical spec without going through
// the URL-recognition path. // the URL-recognition path.
@ -93,6 +99,16 @@ func DefaultProjectMdlTableYAML() []byte { return embeddedDefaultProjectMdlTable
// rsk.table.yaml bytes. // rsk.table.yaml bytes.
func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable } func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable }
// DefaultProjectMdlFormYAML returns the embedded project-rollup
// mdl.form.yaml bytes. Differs from the per-party MDL form by an
// additional required `party` field — the routing key for the
// rollup create handler.
func DefaultProjectMdlFormYAML() []byte { return embeddedDefaultProjectMdlForm }
// DefaultProjectRskFormYAML returns the embedded project-rollup
// rsk.form.yaml bytes.
func DefaultProjectRskFormYAML() []byte { return embeddedDefaultProjectRskForm }
// IsDefaultSpec reports whether urlPath is one of the embedded // IsDefaultSpec reports whether urlPath is one of the embedded
// default-spec virtual files served when no operator file exists on // default-spec virtual files served when no operator file exists on
// disk. Recognized URL shapes: // disk. Recognized URL shapes:
@ -195,14 +211,14 @@ func classifyDefaultSpec(rel string) []byte {
case "table.yaml": case "table.yaml":
return embeddedDefaultProjectMdlTable return embeddedDefaultProjectMdlTable
case "form.yaml": case "form.yaml":
return embeddedDefaultMdlForm return embeddedDefaultProjectMdlForm
} }
case "rsk": case "rsk":
switch file { switch file {
case "table.yaml": case "table.yaml":
return embeddedDefaultProjectRskTable return embeddedDefaultProjectRskTable
case "form.yaml": case "form.yaml":
return embeddedDefaultRskForm return embeddedDefaultProjectRskForm
} }
} }
} }

View file

@ -1511,7 +1511,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 12:37:53 · 847e082-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 13:13:20 · cef7188-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -253,6 +253,30 @@ func IsSSRCreateURL(urlPath string) (string, bool) {
return project, true return project, true
} }
// IsRollupCreateURL reports whether urlPath is
// /<project>/(mdl|rsk)/form.html — the "+ Add row" target on a
// project-level MDL or RSK rollup view. Returns the project name +
// slot ("mdl" or "rsk") when matched. The rollup-create handler
// reads a `party` field from the body and routes the new row into
// <project>/archive/<party>/<slot>/.
func IsRollupCreateURL(urlPath string) (project, slot string, ok bool) {
if urlPath == "" || urlPath[0] != '/' {
return "", "", false
}
parts := strings.Split(strings.TrimPrefix(urlPath, "/"), "/")
if len(parts) != 3 || parts[2] != "form.html" {
return "", "", false
}
if parts[1] != "mdl" && parts[1] != "rsk" {
return "", "", false
}
project = parts[0]
if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") {
return "", "", false
}
return project, parts[1], true
}
// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff // StripYAMLHTML returns urlPath with a trailing ".html" stripped iff
// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise // the URL has the form-edit shape ".../<name>.yaml.html". Otherwise
// returns urlPath unchanged + false. The form recognizer calls this // returns urlPath unchanged + false. The form recognizer calls this