From f3d334a221c1c34c7b912b3a75cc4e3d552ff342 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 19 May 2026 08:14:37 -0500 Subject: [PATCH] feat(tables): rollup Add Row routes via the party column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /archive// exists on disk, strips the field, and writes the row into archive///-.yaml. The response Location is the synthetic //__.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) --- .../handler/default-project-mdl.form.yaml | 91 +++++++++++++ .../handler/default-project-mdl.table.yaml | 13 +- .../handler/default-project-rsk.form.yaml | 82 +++++++++++ .../handler/default-project-rsk.table.yaml | 13 +- zddc/internal/handler/formhandler.go | 29 +++- zddc/internal/handler/ssrhandler.go | 128 ++++++++++++++++++ zddc/internal/handler/tablehandler.go | 20 ++- zddc/internal/handler/tables.html | 2 +- zddc/internal/zddc/virtualviews.go | 24 ++++ 9 files changed, 383 insertions(+), 19 deletions(-) create mode 100644 zddc/internal/handler/default-project-mdl.form.yaml create mode 100644 zddc/internal/handler/default-project-rsk.form.yaml diff --git a/zddc/internal/handler/default-project-mdl.form.yaml b/zddc/internal/handler/default-project-mdl.form.yaml new file mode 100644 index 0000000..423a7b1 --- /dev/null +++ b/zddc/internal/handler/default-project-mdl.form.yaml @@ -0,0 +1,91 @@ +# Default project-rollup MDL row schema, served by zddc-server when +# no operator-supplied form.yaml exists at /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 /mdl/form.html, +# finds the matching /archive// 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 /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//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 /archive// 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 diff --git a/zddc/internal/handler/default-project-mdl.table.yaml b/zddc/internal/handler/default-project-mdl.table.yaml index 2f81852..4675447 100644 --- a/zddc/internal/handler/default-project-mdl.table.yaml +++ b/zddc/internal/handler/default-project-mdl.table.yaml @@ -8,15 +8,14 @@ # column is derived from the row's source folder (path-injected by # the server, not stored in the YAML). # -# + Add row is suppressed in this view because the party affiliation -# would be ambiguous — add deliverables at the per-party path -# (/archive//mdl/) and they'll appear here on next -# load. +# + Add row IS enabled here: the `party` column doubles as the +# routing key — the server reads the submitted `party` field, finds +# the matching /archive// folder, and writes the row +# inside its mdl/ subfolder. The party folder must already exist +# (create it via the SSR view). 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. - -addable: false +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//mdl/ folder. columns: - field: party diff --git a/zddc/internal/handler/default-project-rsk.form.yaml b/zddc/internal/handler/default-project-rsk.form.yaml new file mode 100644 index 0000000..a03b09f --- /dev/null +++ b/zddc/internal/handler/default-project-rsk.form.yaml @@ -0,0 +1,82 @@ +# Default project-rollup RSK row schema, served by zddc-server when +# no operator-supplied form.yaml exists at /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 /rsk/form.html and routes the row to the matching +# /archive//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 /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//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 /archive// 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 diff --git a/zddc/internal/handler/default-project-rsk.table.yaml b/zddc/internal/handler/default-project-rsk.table.yaml index f00e84f..1176925 100644 --- a/zddc/internal/handler/default-project-rsk.table.yaml +++ b/zddc/internal/handler/default-project-rsk.table.yaml @@ -7,15 +7,14 @@ # column is derived from the row's source folder (path-injected by # the server, not stored in the YAML). # -# + Add row is suppressed in this view because the party affiliation -# would be ambiguous — add risks at the per-party path -# (/archive//rsk/) and they'll appear here on next -# load. +# + Add row IS enabled here: the `party` column doubles as the +# routing key — the server reads the submitted `party` field, finds +# the matching /archive// folder, and writes the row +# inside its rsk/ subfolder. The party folder must already exist +# (create it via the SSR view). 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. - -addable: false +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//rsk/ folder. columns: - field: party diff --git a/zddc/internal/handler/formhandler.go b/zddc/internal/handler/formhandler.go index dfe615e..abaeef6 100644 --- a/zddc/internal/handler/formhandler.go +++ b/zddc/internal/handler/formhandler.go @@ -89,9 +89,12 @@ type FormRequest struct { // SubmitURL is the URL the form should POST back to (the server-injected // "submit to my own URL" value). SubmitURL string - // Project carries the project name for create-via-ssr requests. Empty - // for all other kinds. + // Project carries the project name for create-via-ssr / + // create-via-rollup requests. Empty for all other kinds. 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 @@ -130,6 +133,26 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest { } } + // Project-rollup MDL / RSK create: //(mdl|rsk)/form.html + // reads a `party` field from the body and routes the new row to + // /archive///. Recognized before the generic + // //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") // 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) case "create-via-ssr": serveFormCreateSSR(cfg, req, w, r) + case "create-via-rollup": + serveFormCreateRollup(cfg, req, w, r) default: http.Error(w, "unknown form request kind", http.StatusInternalServerError) } diff --git a/zddc/internal/handler/ssrhandler.go b/zddc/internal/handler/ssrhandler.go index 960c133..f9372aa 100644 --- a/zddc/internal/handler/ssrhandler.go +++ b/zddc/internal/handler/ssrhandler.go @@ -30,6 +30,7 @@ import ( "os" "path/filepath" "strings" + "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "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) } +// 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 //(mdl|rsk)/form.html +// +// Content-Type: application/yaml | application/json +// body: { party: "", ...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 +// /archive///, same chain the generic +// serveFormCreate would gate against if the user were on the +// per-party path directly. +// +// On success: 201 + Location: ///__.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 /archive///. authorizeAction + // walks up to the closest existing ancestor (typically /), 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. // // Wire-form: POST //ssr/.yaml diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index 583b3b2..f542f15 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -65,6 +65,12 @@ var embeddedDefaultProjectMdlTable []byte //go:embed default-project-rsk.table.yaml 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. // Used by callers that need the canonical spec without going through // the URL-recognition path. @@ -93,6 +99,16 @@ func DefaultProjectMdlTableYAML() []byte { return embeddedDefaultProjectMdlTable // rsk.table.yaml bytes. 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 // default-spec virtual files served when no operator file exists on // disk. Recognized URL shapes: @@ -195,14 +211,14 @@ func classifyDefaultSpec(rel string) []byte { case "table.yaml": return embeddedDefaultProjectMdlTable case "form.yaml": - return embeddedDefaultMdlForm + return embeddedDefaultProjectMdlForm } case "rsk": switch file { case "table.yaml": return embeddedDefaultProjectRskTable case "form.yaml": - return embeddedDefaultRskForm + return embeddedDefaultProjectRskForm } } } diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 4a0b219..c5cda0d 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1511,7 +1511,7 @@ body.is-elevated::after {
ZDDC Table - v0.0.17-alpha · 2026-05-19 12:37:53 · 847e082-dirty + v0.0.17-alpha · 2026-05-19 13:13:20 · cef7188-dirty
diff --git a/zddc/internal/zddc/virtualviews.go b/zddc/internal/zddc/virtualviews.go index 141f8e2..b961678 100644 --- a/zddc/internal/zddc/virtualviews.go +++ b/zddc/internal/zddc/virtualviews.go @@ -253,6 +253,30 @@ func IsSSRCreateURL(urlPath string) (string, bool) { return project, true } +// IsRollupCreateURL reports whether urlPath is +// //(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 +// /archive///. +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 // the URL has the form-edit shape ".../.yaml.html". Otherwise // returns urlPath unchanged + false. The form recognizer calls this