From 73e34bed5ed260ab1cbc7b5a06726cc6839a9345 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 18 May 2026 21:47:56 -0500 Subject: [PATCH] feat: per-party RSK + project-level SSR/MDL/RSK rollup tables Adds the risk register as a sibling of MDL under archive//, and three project-level virtual aggregations at /{ssr,mdl,rsk}: - SSR aggregates archive//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// 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) --- tables/js/context.js | 5 + tables/js/main.js | 6 +- zddc/cmd/zddc-server/main.go | 37 ++- zddc/internal/fs/tree.go | 62 ++++ .../handler/default-project-mdl.table.yaml | 69 +++++ .../handler/default-project-rsk.table.yaml | 56 ++++ zddc/internal/handler/default-rsk.form.yaml | 83 +++++ zddc/internal/handler/default-rsk.table.yaml | 51 ++++ zddc/internal/handler/default-ssr.form.yaml | 76 +++++ zddc/internal/handler/default-ssr.table.yaml | 62 ++++ zddc/internal/handler/fileapi.go | 36 +++ zddc/internal/handler/formhandler.go | 83 ++++- zddc/internal/handler/ssrhandler.go | 287 ++++++++++++++++++ zddc/internal/handler/ssrhandler_test.go | 214 +++++++++++++ zddc/internal/handler/tablehandler.go | 267 ++++++++++------ zddc/internal/handler/tablehandler_test.go | 112 ++++++- zddc/internal/handler/tables.html | 13 +- zddc/internal/handler/virtualviewhandler.go | 101 ++++++ zddc/internal/zddc/defaults.zddc.yaml | 33 ++ zddc/internal/zddc/lookups_test.go | 5 +- 20 files changed, 1540 insertions(+), 118 deletions(-) create mode 100644 zddc/internal/handler/default-project-mdl.table.yaml create mode 100644 zddc/internal/handler/default-project-rsk.table.yaml create mode 100644 zddc/internal/handler/default-rsk.form.yaml create mode 100644 zddc/internal/handler/default-rsk.table.yaml create mode 100644 zddc/internal/handler/default-ssr.form.yaml create mode 100644 zddc/internal/handler/default-ssr.table.yaml create mode 100644 zddc/internal/handler/ssrhandler.go create mode 100644 zddc/internal/handler/ssrhandler_test.go create mode 100644 zddc/internal/handler/virtualviewhandler.go diff --git a/tables/js/context.js b/tables/js/context.js index 5f77c6a..8a7b88d 100644 --- a/tables/js/context.js +++ b/tables/js/context.js @@ -111,6 +111,11 @@ description: spec.description, columns: spec.columns, defaults: spec.defaults, + // addable defaults to true; tables can opt out with + // `addable: false` (used by project-rollup MDL/RSK where the + // party affiliation of a new row is ambiguous — add at the + // per-party path instead). + addable: spec.addable !== false, rowSchema: rowSchema, rows: rows }; diff --git a/tables/js/main.js b/tables/js/main.js index 3caef80..bf73833 100644 --- a/tables/js/main.js +++ b/tables/js/main.js @@ -41,7 +41,11 @@ if (addRowBtn) { const onHttp = location.protocol === 'http:' || location.protocol === 'https:'; const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0; - if (onHttp && hasCols) { + // ctx.addable === false suppresses the affordance entirely. + // Used by project-rollup tables where the row's party + // affiliation is ambiguous (add at the per-party path). + const allowAdd = ctx.addable !== false; + if (onHttp && hasCols && allowAdd) { addRowBtn.hidden = false; addRowBtn.removeAttribute('href'); addRowBtn.setAttribute('role', 'button'); diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 24d97e5..7d28625 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -1002,13 +1002,20 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps info, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { - // Default MDL spec fallback: archive//mdl.table.yaml - // and archive//mdl.form.yaml are served from embedded - // bytes when no operator file exists on disk. The table app - // fetches these client-side; the fallback lets a fresh - // project work out of the box. + // Default-spec fallback for the embedded table.yaml / form.yaml + // files served when no operator file exists on disk: + // + // /archive//{mdl,rsk}/{table,form}.yaml + // /archive//ssr.form.yaml + // /{ssr,mdl,rsk}/{table,form}.yaml + // + // The table app fetches these client-side; the fallback lets + // a fresh project work out of the box. ACL gates against the + // chain at the request directory; for project-level virtual + // specs that chain is the project's, and for per-party paths + // it's the party's archive folder. if r.Method == http.MethodGet || r.Method == http.MethodHead { - if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok { + if bytes, ok := handler.IsDefaultSpec(cfg.Root, urlPath); ok { chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath)) if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) @@ -1016,7 +1023,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } w.Header().Set("Content-Type", "application/yaml; charset=utf-8") w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-ZDDC-Source", "default-mdl-spec") + w.Header().Set("X-ZDDC-Source", "default-spec") if r.Method == http.MethodHead { return } @@ -1024,6 +1031,22 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } } + // Virtual project-level table views (SSR / MDL rollup / RSK + // rollup). The virtual row URL doesn't exist on disk; the + // underlying canonical file lives in /archive//. + // ACL evaluates against the canonical party-archive path so + // non-owners see the row read-only and party owners can edit. + if r.Method == http.MethodGet || r.Method == http.MethodHead { + if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind.IsRowKind() { + chain, _ := zddc.EffectivePolicy(cfg.Root, vv.PartyArchive) + if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + handler.ServeVirtualViewRow(w, r, vv) + return + } + } // File doesn't exist at this path. Before falling through to // app-HTML routing or 404, check the two virtual-file-extension // shapes that ZDDC exposes through the listing convention: diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 4c4cfdb..948d5be 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -212,6 +212,68 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // to real ones. result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...) + // Project-level virtual table views: SSR aggregates one row per + // party folder under archive/; MDL/RSK rollups aggregate every + // row from each party's mdl/ or rsk/. The listing surfaces + // synthetic row entries (Writable bit per the canonical + // archive// chain) plus synthetic table.yaml/form.yaml + // entries so the tables tool's client-side walkServer finds the + // spec without a 404 round-trip. Spec bytes are served by the + // main.go IsDefaultSpec fallback; row reads go through + // handler.ServeVirtualViewRow which path-injects name/party. + if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() { + partyChains := make(map[string]zddc.PolicyChain) + chainFor := func(partyAbs string) zddc.PolicyChain { + if c, ok := partyChains[partyAbs]; ok { + return c + } + c, _ := zddc.EffectivePolicy(fsRoot, partyAbs) + partyChains[partyAbs] = c + return c + } + appendVirtualRow := func(syntheticName, partyAbs string) { + rowURL := baseURL + url.PathEscape(syntheticName) + chain := chainFor(partyAbs) + if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, rowURL); !allowed { + return + } + partyActiveAdmin := elevated && userEmail != "" && + zddc.IsAdminForChain(chain, userEmail) + writable := partyActiveAdmin + if !writable { + allowed, _ := policy.AllowActionFromChainP(ctx, decider, chain, principal, rowURL, policy.ActionWrite) + writable = allowed + } + result = append(result, listing.FileInfo{ + Name: syntheticName, + URL: rowURL, + IsDir: false, + Virtual: true, + Writable: writable, + }) + } + + switch vv.Slot { + case "ssr": + parties, _ := zddc.ListSSRParties(fsRoot, vv.ProjectAbs) + for _, party := range parties { + partyAbs := filepath.Join(vv.ProjectAbs, "archive", party) + appendVirtualRow(party+".yaml", partyAbs) + } + case "mdl", "rsk": + rows, _ := zddc.ListRollupRows(fsRoot, vv.ProjectAbs, vv.Slot) + for _, row := range rows { + partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party) + appendVirtualRow(row.SyntheticName, partyAbs) + } + } + + result = append(result, + listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true}, + listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true}, + ) + } + // Workflow folder: append a virtual `received/` entry whose backing // is .zddc.received_path. The entry's URL stays under the workflow // folder (baseURL + "received/") so a click navigates "into" the diff --git a/zddc/internal/handler/default-project-mdl.table.yaml b/zddc/internal/handler/default-project-mdl.table.yaml new file mode 100644 index 0000000..2f81852 --- /dev/null +++ b/zddc/internal/handler/default-project-mdl.table.yaml @@ -0,0 +1,69 @@ +# Default project-rollup Master Deliverables List spec, served by +# zddc-server when no operator-supplied table.yaml exists at +# /mdl/. +# +# This view aggregates every deliverable row from every party under +# /archive/. Each synthetic row is backed by the real file +# at /archive//mdl/.yaml; the leading `party` +# 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. + +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 + +columns: + - field: party + title: Package + width: 7em + - field: originator + title: Originator + width: 8em + - field: phase + title: Phase + width: 5em + - field: project + title: Project + width: 8em + - field: area + title: Area + width: 5em + - field: discipline + title: Disc. + width: 5em + - field: type + title: Type + width: 6em + - field: sequence + title: Seq. + width: 5em + - field: suffix + title: Suffix + width: 5em + - field: title + title: Deliverable + - field: plannedRevision + title: Rev. + width: 5em + - field: plannedDate + title: Planned + format: date + width: 8em + - field: status + title: Status + width: 6em + enum: [DFT, IFR, IFA, IFC, AFC, AB] + - field: owner + title: Owner + width: 12em + +defaults: + sort: + - { field: party, dir: asc } + - { field: plannedDate, dir: asc } diff --git a/zddc/internal/handler/default-project-rsk.table.yaml b/zddc/internal/handler/default-project-rsk.table.yaml new file mode 100644 index 0000000..f00e84f --- /dev/null +++ b/zddc/internal/handler/default-project-rsk.table.yaml @@ -0,0 +1,56 @@ +# Default project-rollup Risk Register spec, served by zddc-server +# when no operator-supplied table.yaml exists at /rsk/. +# +# This view aggregates every risk row from every party under +# /archive/. Each synthetic row is backed by the real file +# at /archive//rsk/.yaml; the leading `party` +# 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. + +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 + +columns: + - field: party + title: Package + width: 7em + - field: id + title: ID + width: 6em + - field: title + title: Risk + - field: category + title: Category + width: 10em + - field: likelihood + title: L + width: 4em + - field: impact + title: I + width: 4em + - field: severity + title: Sev + width: 5em + - field: owner + title: Owner + width: 12em + - field: status + title: Status + width: 9em + enum: [open, mitigated, accepted, closed] + - field: dueDate + title: Due + format: date + width: 8em + +defaults: + sort: + - { field: severity, dir: desc } + - { field: party, dir: asc } diff --git a/zddc/internal/handler/default-rsk.form.yaml b/zddc/internal/handler/default-rsk.form.yaml new file mode 100644 index 0000000..b8ab03b --- /dev/null +++ b/zddc/internal/handler/default-rsk.form.yaml @@ -0,0 +1,83 @@ +# Default row schema for a Risk Register entry, served by +# zddc-server when no operator-supplied form.yaml exists at +# archive//rsk/. +# +# Likelihood and impact use the standard 1-5 ordinal scales; +# severity is also 1-25 (typically L*I) and stored on each row so +# operators can override it when the simple product doesn't capture +# the actual risk profile. +# +# To customize: drop your own form.yaml into archive//rsk/ +# (the same directory as table.yaml). Tighten constraints with +# `enum:`, `pattern:`, etc. Add fields and they'll appear in the +# row-edit form; add a matching column to table.yaml to surface +# the field in the table view too. + +title: Risk +description: One identified risk. Likelihood and impact use 1-5 ordinals; severity is stored separately so it can be overridden when L*I underrepresents the residual exposure. + +schema: + type: object + required: [id, title] + additionalProperties: false + properties: + 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: Free-form grouping (schedule, cost, technical, regulatory, ...). + description: + type: string + title: Description + likelihood: + type: integer + title: Likelihood + description: 1 (rare) to 5 (almost certain). + minimum: 1 + maximum: 5 + impact: + type: integer + title: Impact + description: 1 (negligible) to 5 (catastrophic). + minimum: 1 + maximum: 5 + severity: + type: integer + title: Severity + description: Residual risk score. Typically likelihood * impact (1-25), but operators can override. + minimum: 1 + maximum: 25 + mitigation: + type: string + title: Mitigation + description: Plan for reducing this risk's likelihood or impact. + owner: + type: string + title: Owner + description: Email or party name responsible for tracking this risk. + 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-rsk.table.yaml b/zddc/internal/handler/default-rsk.table.yaml new file mode 100644 index 0000000..a935d52 --- /dev/null +++ b/zddc/internal/handler/default-rsk.table.yaml @@ -0,0 +1,51 @@ +# Default Risk Register spec, served by zddc-server when no +# operator-supplied table.yaml exists at archive//rsk/. +# +# Columns cover the standard risk-register fields: identifier, title, +# category, likelihood / impact / severity scores, owner, status, and +# due date. Severity is stored on each row (1-25, typically L*I) so +# operators can override it when the simple product doesn't capture +# the actual risk profile. +# +# To customize: drop your own table.yaml + form.yaml into the same +# directory (archive//rsk/). The whole directory IS the table — +# spec, row-edit form, and rows are siblings. Override examples mirror +# the MDL table.yaml customization patterns. + +title: Risk Register +description: Risks tracked for this party. Severity is the residual risk score; sort defaults to severity descending. + +columns: + - field: id + title: ID + width: 6em + - field: title + title: Risk + - field: category + title: Category + width: 10em + - field: likelihood + title: L + width: 4em + - field: impact + title: I + width: 4em + - field: severity + title: Sev + width: 5em + - field: owner + title: Owner + width: 12em + - field: status + title: Status + width: 9em + enum: [open, mitigated, accepted, closed] + - field: dueDate + title: Due + format: date + width: 8em + +defaults: + sort: + - { field: severity, dir: desc } + - { field: dueDate, dir: asc } diff --git a/zddc/internal/handler/default-ssr.form.yaml b/zddc/internal/handler/default-ssr.form.yaml new file mode 100644 index 0000000..1e16c29 --- /dev/null +++ b/zddc/internal/handler/default-ssr.form.yaml @@ -0,0 +1,76 @@ +# Default row schema for a Supplier / Subcontractor Status Report +# entry, served by zddc-server when no operator-supplied form.yaml +# exists at /archive//ssr.form.yaml. +# +# The `name` field doubles as the party folder name (the row's +# stable identifier). It's required on create (+ Add row materializes +# /archive//) but is stripped from the YAML on save — +# the folder name IS the identity, so storing it inside the file too +# would just be a denormalization. On read the dispatcher injects +# name back into the row data so this form (and the SSR table) +# can display it. +# +# Pattern excludes leading `.` and `_` to avoid colliding with +# fileapi.go's dot/underscore-prefix guards on file paths. +# +# To customize: drop your own form.yaml into +# /archive// (sibling to the party's ssr.yaml). + +title: Supplier / Subcontractor Status +description: One party's status report. The party name doubles as the archive folder name and is required when creating a new row. + +schema: + type: object + required: [name, vendorType, contractNo, scopeSummary] + additionalProperties: false + properties: + name: + type: string + title: Party (folder name) + description: Becomes /archive//. Typical naming = MasterFormat 4-digit code + C|P + sequence digit (e.g. 0330C1). + pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$" + minLength: 1 + vendorType: + type: string + title: Vendor type + enum: [subcontractor, supplier, consultant, vendor, other] + contractNo: + type: string + title: Contract / PO number + scopeSummary: + type: string + title: Scope summary + contractValue: + type: number + title: Contract value + awardDate: + type: string + title: Award date + format: date + kickoffDate: + type: string + title: Kickoff date + format: date + scheduleStatus: + type: string + title: Schedule status + enum: [on-track, at-risk, behind, completed, on-hold] + deliverablesStatus: + type: string + title: Deliverables status + enum: [on-track, at-risk, behind, completed] + paymentStatus: + type: string + title: Payment status + enum: [current, overdue, hold, complete] + ownerContact: + type: string + title: Owner contact (email) + notes: + type: string + title: Notes +ui: + scopeSummary: + ui:widget: textarea + notes: + ui:widget: textarea diff --git a/zddc/internal/handler/default-ssr.table.yaml b/zddc/internal/handler/default-ssr.table.yaml new file mode 100644 index 0000000..b0efdcf --- /dev/null +++ b/zddc/internal/handler/default-ssr.table.yaml @@ -0,0 +1,62 @@ +# Default Supplier / Subcontractor Status Report spec, served by +# zddc-server when no operator-supplied table.yaml exists at +# /ssr/. +# +# The SSR is a project-level aggregation: one row per party folder +# under /archive/, each row backed by +# /archive//ssr.yaml. The synthetic `name` column +# shows the party folder name (which is the row's stable identifier); +# typical naming encodes a MasterFormat 4-digit code plus C|P plus +# a sequence digit (e.g. 0330C1, 0440P2). +# +# To customize: drop your own table.yaml + form.yaml at +# /ssr/table.yaml + form.yaml (the cascade declares +# /ssr/ as virtual, but the spec files themselves can be +# real overrides). Add columns or tighten enums as your project's +# subcontract reporting requires. + +title: Supplier / Subcontractor Status +description: One row per party folder under archive/. Click + Add row to create a new party (folder + metadata). + +columns: + - field: name + title: Party + width: 8em + - field: vendorType + title: Type + width: 9em + - field: contractNo + title: Contract + width: 10em + - field: scopeSummary + title: Scope + - field: contractValue + title: Value + width: 10em + - field: awardDate + title: Award + format: date + width: 8em + - field: kickoffDate + title: Kickoff + format: date + width: 8em + - field: scheduleStatus + title: Schedule + width: 9em + enum: [on-track, at-risk, behind, completed, on-hold] + - field: deliverablesStatus + title: Deliv. + width: 9em + enum: [on-track, at-risk, behind, completed] + - field: paymentStatus + title: Pmt. + width: 8em + enum: [current, overdue, hold, complete] + - field: ownerContact + title: Owner contact + width: 14em + +defaults: + sort: + - { field: name, dir: asc } diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 247d213..c0b8da7 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -50,6 +50,9 @@ const ( opMove = "move" opMkdir = "mkdir" + // opSSRRename / opPlanReview / opAcceptTransmittal are declared + // alongside their handler files. Listed in the dispatch switch + // below so they're discoverable from a single place. ) // IsWriteMethod reports whether this method is handled by the file API. @@ -297,6 +300,22 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } + // Virtual project-level table views — SSR / MDL rollup / RSK + // rollup. The PUT URL lives in /{ssr,mdl,rsk}/...; the + // underlying bytes belong inside /archive//. We + // rewrite abs + cleanURL to the canonical path so the rest of + // this function (ACL gate, ETag, audit, conversion-cache purge) + // operates on the real file location. + // + // SSR row PUTs land at archive//ssr.yaml; MDL/RSK rollup + // row PUTs land at archive///.yaml. Same + // shape as the virtual-received rewrite below. + if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() { + abs = vv.CanonicalAbs + cleanURL = vv.CanonicalURL + w.Header().Set("X-ZDDC-Resolved-Path", cleanURL) + } + // Virtual received/ rewrite. When the PUT targets a file under the // synthetic /received/ URL, the canonical record is // WORM — we can't write there. Convention: treat the drop as a @@ -424,6 +443,21 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) return } + // Virtual project-level table views. SSR row deletes are refused + // (would orphan the party folder and its mdl/rsk contents) — use + // the archive view to delete a party. MDL/RSK rollup row deletes + // pass through to the canonical archive///.yaml + // path with the normal ACL gate. + if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() { + if vv.Kind == zddc.VirtualViewSSRRow { + http.Error(w, "Method Not Allowed — delete the party folder via the archive view, not the SSR table", http.StatusMethodNotAllowed) + return + } + abs = vv.CanonicalAbs + cleanURL = vv.CanonicalURL + w.Header().Set("X-ZDDC-Resolved-Path", cleanURL) + } + info, err := os.Stat(abs) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -473,6 +507,8 @@ func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) { servePlanReview(cfg, w, r) case opAcceptTransmittal: serveAcceptTransmittal(cfg, w, r) + case opSSRRename: + serveSSRRename(cfg, w, r) case "": http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest) default: diff --git a/zddc/internal/handler/formhandler.go b/zddc/internal/handler/formhandler.go index 8cb65dd..dfe615e 100644 --- a/zddc/internal/handler/formhandler.go +++ b/zddc/internal/handler/formhandler.go @@ -77,16 +77,21 @@ type formContext struct { // FormRequest describes a recognized form-system request. type FormRequest struct { - // Kind is one of: "render-empty", "create", "render-edit", "update". + // Kind is one of: "render-empty", "create", "render-edit", "update", + // or "create-via-ssr" (the special SSR create flow which materializes + // a new party folder + ssr.yaml). Kind string // SpecPath is the absolute filesystem path to the .form.yaml. SpecPath string // DataPath is the absolute filesystem path to the data .yaml; empty for - // render-empty / create. + // render-empty / create / create-via-ssr. DataPath string // 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 string } // RecognizeFormRequest classifies r as a form-system request, or returns nil @@ -103,17 +108,38 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest { if !strings.HasSuffix(urlPath, ".html") { return nil } + + // SSR create: //ssr/form.html maps to the special create + // path that materializes a new party folder (mkdir archive//) + // AND writes archive//ssr.yaml. Recognized before the generic + // form.html branch so it doesn't get misrouted as an in-dir create. + if project, ok := zddc.IsSSRCreateURL(urlPath); ok { + kind := "render-empty" + if method == http.MethodPost { + kind = "create-via-ssr" + } + // SpecPath is the embedded default SSR form schema; the loader + // falls back to embedded bytes via IsDefaultSpecAbs. The path + // itself is the virtual /ssr/form.yaml location. + specAbs := filepath.Join(fsRoot, project, "ssr", "form.yaml") + return &FormRequest{ + Kind: kind, + SpecPath: specAbs, + SubmitURL: urlPath, + Project: project, + } + } + underlying := strings.TrimSuffix(urlPath, ".html") // specEligible accepts a spec path that exists on disk OR matches - // the default-MDL virtual-fallback shape at archive//mdl/. - // Without this, the default-MDL row form would 404 on a fresh - // archive even though the table view renders. + // any of the default-spec virtual-fallback shapes (per-party + // mdl/rsk, per-party SSR schema, project-level virtual specs). specEligible := func(specAbs string) bool { if fileExists(specAbs) { return true } - if _, ok := IsDefaultMdlSpecAbs(fsRoot, specAbs); ok { + if _, ok := IsDefaultSpecAbs(fsRoot, specAbs); ok { return true } return false @@ -154,7 +180,36 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest { if strings.HasSuffix(underlying, ".yaml") { // //.yaml.html — re-edit / update. Spec lives in the - // SAME directory as the row file (/form.yaml). + // SAME directory as the row file (/form.yaml) UNLESS the + // URL maps to one of the project-level virtual views, in which + // case the canonical SpecPath / DataPath are inside the per- + // party archive folder. ResolveVirtualView handles the rewrite; + // SubmitURL stays as the virtual URL so the form POSTs back to + // the same endpoint (which re-resolves to the same canonical + // paths on the second pass). + if vv := zddc.ResolveVirtualView(fsRoot, underlying); vv.Resolved && vv.Kind.IsRowKind() { + var specPath string + switch vv.Kind { + case zddc.VirtualViewSSRRow: + specPath = vv.SchemaAbs + case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow: + specPath = filepath.Join(vv.PartyArchive, vv.Slot, "form.yaml") + } + if !specEligible(specPath) { + return nil + } + kind := "render-edit" + if method == http.MethodPost { + kind = "update" + } + return &FormRequest{ + Kind: kind, + SpecPath: specPath, + DataPath: vv.CanonicalAbs, + SubmitURL: urlPath, + } + } + dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) dataAbs := filepath.Join(fsRoot, dataRel) if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot { @@ -192,6 +247,8 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht serveFormCreate(cfg, req, w, r) case "update": serveFormUpdate(cfg, req, w, r) + case "create-via-ssr": + serveFormCreateSSR(cfg, req, w, r) default: http.Error(w, "unknown form request kind", http.StatusInternalServerError) } @@ -417,13 +474,13 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter, func loadFormSpec(fsRoot, path string) (*FormSpec, error) { data, err := os.ReadFile(path) if err != nil { - // Default-MDL virtual fallback: when the operator hasn't placed - // an mdl.form.yaml under archive//, serve the embedded - // default. Mirrors the static-handler fallback for direct YAML - // fetches so the form recognizer and the loader agree on what - // "this spec exists" means. + // Default-spec virtual fallback: when no operator file exists at + // path, serve the embedded default if path matches one of the + // recognized virtual fallback shapes (per-party mdl/rsk, per- + // party SSR schema, project-level virtual specs). Mirrors the + // static-handler fallback for direct YAML fetches. if os.IsNotExist(err) { - if bytes, ok := IsDefaultMdlSpecAbs(fsRoot, path); ok { + if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok { data = bytes } else { return nil, err diff --git a/zddc/internal/handler/ssrhandler.go b/zddc/internal/handler/ssrhandler.go new file mode 100644 index 0000000..960c133 --- /dev/null +++ b/zddc/internal/handler/ssrhandler.go @@ -0,0 +1,287 @@ +// Package handler — ssrhandler.go: Supplier/Subcontractor Status Report +// lifecycle endpoints that don't fit the generic form / file API shapes. +// +// Two endpoints live here: +// +// POST //ssr/form.html → "+ Add row" / SSR create +// POST //ssr/.yaml with X-ZDDC-Op: ssr-rename +// X-ZDDC-Destination: //ssr/.yaml +// +// Both target the project-level SSR aggregator, which is a virtual view +// (see zddc/virtualviews.go). Internally they materialize / rename real +// party folders under /archive/. +// +// The generic file API only does file-level moves; renaming a party +// folder means renaming a directory and bringing every row inside with +// it. Rather than weaken serveFileMove's IsDir-source guard (which keeps +// callers from accidentally moving entire subtrees), this file +// implements ssr-rename as a tightly-scoped op that only fires for the +// SSR virtual URL shape. + +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema" + "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" + "gopkg.in/yaml.v3" +) + +// opSSRRename is the X-ZDDC-Op value that fires serveSSRRename. Dispatched +// from serveFilePost in fileapi.go. +const opSSRRename = "ssr-rename" + +// serveFormCreateSSR materializes a new party folder under +// /archive// and writes archive//ssr.yaml from the +// submitted form body. The `name` field doubles as the party folder +// name and is stripped from the YAML before write (path-derived). +// +// ACL gate: ActionCreate at /archive// — typically +// satisfied by document_controller's rwc grant on archive/ in the +// project-level cascade, OR by a deployment that grants `c` to a wider +// audience. +// +// Auto-own .zddc is seeded for the new party folder via +// WriteAutoOwnZddc, the same machinery the generic mkdir uses. +// Authenticated email is required (401 otherwise) so the auto-own +// grant always names a real principal. +func serveFormCreateSSR(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 == "" { + http.Error(w, "internal: SSR create missing project", 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 + } + nameRaw, _ := dataMap["name"].(string) + name := strings.TrimSpace(nameRaw) + if !zddc.ValidPartyName(name) { + writeValidationErrors(w, []jsonschema.Error{{ + Path: "/name", + Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`, + }}) + return + } + + partyAbs := filepath.Join(cfg.Root, req.Project, "archive", name) + if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + partyURL := "/" + req.Project + "/archive/" + name + "/" + rowURL := "/" + req.Project + "/ssr/" + name + ".yaml" + + // ACL gate: create at /archive//. authorizeAction walks + // up to the closest existing ancestor for the chain — typically + // /archive/, where document_controller carries rwc per the + // project-level cascade. + if !authorizeAction(cfg, w, r, partyAbs, partyURL, policy.ActionCreate) { + return + } + + // Refuse to clobber an existing party folder. The SSR view shows + // any folder under archive/*/; if one with this name exists, the + // user should edit that row instead of creating a duplicate. + if info, err := os.Stat(partyAbs); err == nil { + if info.IsDir() { + http.Error(w, "Conflict — a party folder with that name already exists", http.StatusConflict) + return + } + http.Error(w, "Conflict — a file exists at this path", http.StatusConflict) + return + } else if !errors.Is(err, os.ErrNotExist) { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Materialize canonical ancestors (/archive/) with auto-own + // seeding before creating the party folder itself. Mirrors what the + // generic file-API mkdir does at fileapi.go:629-634. + yamlAbs := filepath.Join(partyAbs, "ssr.yaml") + if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, yamlAbs, email, 0o755); err != nil { + slog.Warn("ssr-create: ensure canonical ancestors", "path", yamlAbs, "err", err) + } + if err := os.MkdirAll(partyAbs, 0o755); err != nil { + auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + // Auto-own .zddc on the new party folder. archive/*/ is declared + // auto_own in defaults.zddc.yaml, so the unfenced creator grant + // fires here exactly as it would for a manual mkdir. + if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) { + var werr error + if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) { + werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email) + } else { + werr = zddc.WriteAutoOwnZddc(partyAbs, email) + } + if werr != nil { + slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr) + } + } + + // Drop the path-derived `name` field — it's the folder name, not + // row data. The dispatcher re-injects it on read. + delete(dataMap, "name") + + yamlBytes, err := yaml.Marshal(dataMap) + if err != nil { + auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err) + http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError) + return + } + if err := zddc.WriteAtomic(yamlAbs, yamlBytes); err != nil { + auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), err) + http.Error(w, "write: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Location", rowURL) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-ZDDC-Source", "ssr-create") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL))) + auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(yamlBytes), nil) +} + +// serveSSRRename renames a party folder by rewriting an SSR row URL. +// +// Wire-form: POST //ssr/.yaml +// +// X-ZDDC-Op: ssr-rename +// X-ZDDC-Destination: //ssr/.yaml +// +// Both source and destination must resolve as SSR-row virtual URLs in +// the same project. The destination party name must be a valid party +// folder name, must differ from the source, and must not already exist. +// +// ACL: ActionWrite at /archive// AND ActionCreate at +// /archive//. Both gates evaluate against their canonical +// archive paths so non-owners can't rename someone else's party. +// +// On success: os.Rename moves archive// → archive// (an +// atomic directory rename on the same filesystem); every row inside +// follows. No denormalized `party:` field rewrite is needed — the +// MDL/RSK schemas don't carry one. +func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) { + email := EmailFromContext(r) + if email == "" { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + + src := zddc.ResolveVirtualView(cfg.Root, r.URL.Path) + if !src.Resolved || src.Kind != zddc.VirtualViewSSRRow { + http.Error(w, "Bad Request — ssr-rename source must be //ssr/.yaml", http.StatusBadRequest) + return + } + + dstHeader := r.Header.Get(headerDestination) + if dstHeader == "" { + http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest) + return + } + if dec, err := url.PathUnescape(dstHeader); err == nil { + dstHeader = dec + } + dst := zddc.ResolveVirtualView(cfg.Root, dstHeader) + if !dst.Resolved || dst.Kind != zddc.VirtualViewSSRRow { + http.Error(w, "Bad Request — ssr-rename destination must be //ssr/.yaml", http.StatusBadRequest) + return + } + if dst.Project != src.Project { + http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest) + return + } + if dst.Party == src.Party { + http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest) + return + } + + // Source party folder must exist. + srcArchive := src.PartyArchive + if info, err := os.Stat(srcArchive); err != nil || !info.IsDir() { + if err != nil && !errors.Is(err, os.ErrNotExist) { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + http.Error(w, "Not Found", http.StatusNotFound) + return + } + // Destination must not exist. + dstArchive := dst.PartyArchive + if _, err := os.Stat(dstArchive); err == nil { + http.Error(w, "Conflict — destination party folder already exists", http.StatusConflict) + return + } else if !errors.Is(err, os.ErrNotExist) { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // ACL: write on src archive, create on dst archive. URLs include + // the trailing slash convention used elsewhere for directory ops. + srcArchiveURL := "/" + src.Project + "/archive/" + src.Party + "/" + dstArchiveURL := "/" + dst.Project + "/archive/" + dst.Party + "/" + if !authorizeAction(cfg, w, r, srcArchive, srcArchiveURL, policy.ActionWrite) { + return + } + if !authorizeAction(cfg, w, r, dstArchive, dstArchiveURL, policy.ActionCreate) { + return + } + + // Optional If-Match against the source ssr.yaml etag. + if !checkIfMatch(w, r, src.CanonicalAbs) { + return + } + + if err := os.Rename(srcArchive, dstArchive); err != nil { + auditFile(r, "ssr-rename", r.URL.Path, http.StatusInternalServerError, 0, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + newURL := "/" + dst.Project + "/ssr/" + dst.Party + ".yaml" + w.Header().Set("Location", newURL) + w.Header().Set("X-ZDDC-Destination", newURL) + w.Header().Set("X-ZDDC-Source", "ssr-rename") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp, _ := json.Marshal(map[string]string{"location": newURL}) + _, _ = w.Write(resp) + auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil) +} diff --git a/zddc/internal/handler/ssrhandler_test.go b/zddc/internal/handler/ssrhandler_test.go new file mode 100644 index 0000000..88039a7 --- /dev/null +++ b/zddc/internal/handler/ssrhandler_test.go @@ -0,0 +1,214 @@ +package handler + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// ssrTestSetup builds a fresh project root with permissive top-level +// ACL that lets *@example.com create + write anywhere under archive/. +// Returns (cfg, do) where do dispatches a request through the same +// recognize→serve path the production catch-all uses. +func ssrTestSetup(t *testing.T) (config.Config, func(method, target, email, body string, headers map[string]string) *httptest.ResponseRecorder) { + t.Helper() + root := t.TempDir() + // Project root: grant the test cohort rwc at the project level so + // they can create archive// folders. + if err := os.WriteFile(filepath.Join(root, ".zddc"), + []byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + do := func(method, target, email, body string, headers map[string]string) *httptest.ResponseRecorder { + var req *http.Request + if body != "" { + req = httptest.NewRequest(method, target, bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, target, nil) + } + for k, v := range headers { + req.Header.Set(k, v) + } + ctx := context.WithValue(req.Context(), EmailKey, email) + ctx = context.WithValue(ctx, ElevatedKey, true) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + // SSR create flows through RecognizeFormRequest → ServeForm → + // create-via-ssr case. Rename flows through ServeFileAPI's POST + // dispatch (ssr-rename op). + if method == http.MethodPost && strings.Contains(target, "/ssr/") && + strings.HasSuffix(target, ".yaml") { + ServeFileAPI(cfg, rec, req) + return rec + } + formReq := RecognizeFormRequest(cfg.Root, method, target) + if formReq != nil { + ServeForm(cfg, formReq, rec, req) + return rec + } + rec.WriteHeader(http.StatusNotFound) + return rec + } + return cfg, do +} + +func TestSSRCreate_HappyPath(t *testing.T) { + cfg, do := ssrTestSetup(t) + + body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"Concrete works"}` + rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil) + if rec.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if loc := rec.Result().Header.Get("Location"); loc != "/Project/ssr/0330C1.yaml" { + t.Errorf("Location=%q want /Project/ssr/0330C1.yaml", loc) + } + // archive/0330C1/ exists. + partyDir := filepath.Join(cfg.Root, "Project", "archive", "0330C1") + if info, err := os.Stat(partyDir); err != nil || !info.IsDir() { + t.Fatalf("party folder not created: err=%v", err) + } + // .zddc auto-own grant. + zf, err := os.ReadFile(filepath.Join(partyDir, ".zddc")) + if err != nil { + t.Fatalf("read auto-own .zddc: %v", err) + } + if !strings.Contains(string(zf), "casey@example.com") { + t.Errorf("auto-own .zddc missing creator email; got %s", string(zf)) + } + // ssr.yaml exists and contains the submitted fields but NOT `name`. + yamlBytes, err := os.ReadFile(filepath.Join(partyDir, "ssr.yaml")) + if err != nil { + t.Fatalf("read ssr.yaml: %v", err) + } + yaml := string(yamlBytes) + if !strings.Contains(yaml, "contractNo: PO-001") { + t.Errorf("ssr.yaml missing contractNo; got %s", yaml) + } + if strings.Contains(yaml, "name: 0330C1") { + t.Errorf("ssr.yaml should not carry path-derived `name` field; got %s", yaml) + } +} + +func TestSSRCreate_AnonymousRejected(t *testing.T) { + _, do := ssrTestSetup(t) + body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}` + rec := do(http.MethodPost, "/Project/ssr/form.html", "", body, nil) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status=%d want 401; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestSSRCreate_InvalidName(t *testing.T) { + _, do := ssrTestSetup(t) + cases := []string{ + `{"name":".hidden","vendorType":"subcontractor","contractNo":"x","scopeSummary":"x"}`, + `{"name":"with space","vendorType":"subcontractor","contractNo":"x","scopeSummary":"x"}`, + } + for _, body := range cases { + rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil) + if rec.Code != http.StatusUnprocessableEntity && rec.Code != http.StatusBadRequest { + t.Errorf("body=%s status=%d want 422 or 400", body, rec.Code) + } + } +} + +func TestSSRCreate_DuplicateName(t *testing.T) { + cfg, do := ssrTestSetup(t) + body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}` + rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil) + if rec.Code != http.StatusCreated { + t.Fatalf("first create failed: status=%d body=%s", rec.Code, rec.Body.String()) + } + zddc.InvalidateCache(filepath.Join(cfg.Root, "Project", "archive", "0330C1")) + rec = do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil) + if rec.Code != http.StatusConflict { + t.Errorf("duplicate create: status=%d want 409", rec.Code) + } +} + +func TestSSRRename_HappyPath(t *testing.T) { + cfg, do := ssrTestSetup(t) + body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}` + if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil); rec.Code != http.StatusCreated { + t.Fatalf("setup create failed: %d %s", rec.Code, rec.Body.String()) + } + // Drop an MDL row inside the party folder; it should survive the rename. + mdlDir := filepath.Join(cfg.Root, "Project", "archive", "0330C1", "mdl") + if err := os.MkdirAll(mdlDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mdlDir, "D-001.yaml"), []byte("id: D-001\n"), 0o644); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(filepath.Join(cfg.Root, "Project", "archive", "0330C1")) + + rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "", + map[string]string{ + "X-ZDDC-Op": opSSRRename, + "X-ZDDC-Destination": "/Project/ssr/0330C2.yaml", + }) + if rec.Code != http.StatusOK { + t.Fatalf("rename failed: %d body=%s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C1")); !os.IsNotExist(err) { + t.Error("source party folder still exists after rename") + } + if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C2")); err != nil { + t.Errorf("destination party folder not created: %v", err) + } + // MDL row followed the directory rename. + if _, err := os.Stat(filepath.Join(cfg.Root, "Project", "archive", "0330C2", "mdl", "D-001.yaml")); err != nil { + t.Errorf("MDL row did not survive rename: %v", err) + } +} + +func TestSSRRename_CrossProjectRejected(t *testing.T) { + cfg, do := ssrTestSetup(t) + body := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}` + if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", body, nil); rec.Code != http.StatusCreated { + t.Fatalf("setup create failed: %d", rec.Code) + } + zddc.InvalidateCache(filepath.Join(cfg.Root, "Project")) + rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "", + map[string]string{ + "X-ZDDC-Op": opSSRRename, + "X-ZDDC-Destination": "/OtherProject/ssr/0330C1.yaml", + }) + if rec.Code != http.StatusBadRequest { + t.Errorf("cross-project rename: status=%d want 400", rec.Code) + } +} + +func TestSSRRename_DestinationExists(t *testing.T) { + cfg, do := ssrTestSetup(t) + bodyA := `{"name":"0330C1","vendorType":"subcontractor","contractNo":"PO-001","scopeSummary":"x"}` + bodyB := `{"name":"0330C2","vendorType":"subcontractor","contractNo":"PO-002","scopeSummary":"y"}` + for _, b := range []string{bodyA, bodyB} { + if rec := do(http.MethodPost, "/Project/ssr/form.html", "casey@example.com", b, nil); rec.Code != http.StatusCreated { + t.Fatalf("setup create failed: %d %s", rec.Code, rec.Body.String()) + } + } + zddc.InvalidateCache(filepath.Join(cfg.Root, "Project")) + rec := do(http.MethodPost, "/Project/ssr/0330C1.yaml", "casey@example.com", "", + map[string]string{ + "X-ZDDC-Op": opSSRRename, + "X-ZDDC-Destination": "/Project/ssr/0330C2.yaml", + }) + if rec.Code != http.StatusConflict { + t.Errorf("rename to existing: status=%d want 409", rec.Code) + } +} diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index 6d85cea..583b3b2 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -47,56 +47,78 @@ var embeddedDefaultMdlTable []byte //go:embed default-mdl.form.yaml var embeddedDefaultMdlForm []byte +//go:embed default-rsk.table.yaml +var embeddedDefaultRskTable []byte + +//go:embed default-rsk.form.yaml +var embeddedDefaultRskForm []byte + +//go:embed default-ssr.table.yaml +var embeddedDefaultSsrTable []byte + +//go:embed default-ssr.form.yaml +var embeddedDefaultSsrForm []byte + +//go:embed default-project-mdl.table.yaml +var embeddedDefaultProjectMdlTable []byte + +//go:embed default-project-rsk.table.yaml +var embeddedDefaultProjectRskTable []byte + // DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes. -// Used by the static-file handler to serve the default spec at -// archive//mdl.table.yaml when no operator file exists on disk. +// Used by callers that need the canonical spec without going through +// the URL-recognition path. func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable } // DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes. func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm } -// IsDefaultMdlSpec reports whether urlPath is one of the default-MDL -// virtual files served when no operator file exists on disk: +// DefaultRskTableYAML returns the embedded default rsk.table.yaml bytes. +func DefaultRskTableYAML() []byte { return embeddedDefaultRskTable } + +// DefaultRskFormYAML returns the embedded default rsk.form.yaml bytes. +func DefaultRskFormYAML() []byte { return embeddedDefaultRskForm } + +// DefaultSsrTableYAML returns the embedded default ssr.table.yaml bytes. +func DefaultSsrTableYAML() []byte { return embeddedDefaultSsrTable } + +// DefaultSsrFormYAML returns the embedded default ssr.form.yaml bytes. +func DefaultSsrFormYAML() []byte { return embeddedDefaultSsrForm } + +// DefaultProjectMdlTableYAML returns the embedded project-rollup +// mdl.table.yaml bytes. +func DefaultProjectMdlTableYAML() []byte { return embeddedDefaultProjectMdlTable } + +// DefaultProjectRskTableYAML returns the embedded project-rollup +// rsk.table.yaml bytes. +func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable } + +// IsDefaultSpec reports whether urlPath is one of the embedded +// default-spec virtual files served when no operator file exists on +// disk. Recognized URL shapes: // -// /archive//mdl/table.yaml -// /archive//mdl/form.yaml +// /archive//mdl/{table.yaml, form.yaml} +// /archive//rsk/{table.yaml, form.yaml} +// /archive//ssr.form.yaml +// /ssr/{table.yaml, form.yaml} +// /mdl/{table.yaml, form.yaml} +// /rsk/{table.yaml, form.yaml} // -// The MDL files live INSIDE the rows-dir along with row YAMLs so the -// whole directory is self-contained — copying mdl/ moves the spec, -// the form, and all rows together. Returns embedded bytes + true when -// the fallback should fire; nil + false when an operator-supplied -// file exists or the path is not eligible. -func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) { - base := strings.ToLower(filepath.Base(urlPath)) - var bytes []byte - switch base { - case "table.yaml": - bytes = embeddedDefaultMdlTable - case "form.yaml": - bytes = embeddedDefaultMdlForm - default: - return nil, false - } - if !isAtArchivePartyMdlLevel(fsRoot, urlPath) { - return nil, false - } - // Operator file wins if it exists on disk. +// Returns embedded bytes + true when the fallback should fire; nil + +// false when an operator file exists at that path or the URL is not +// eligible. Operator files always win. +func IsDefaultSpec(fsRoot, urlPath string) ([]byte, bool) { rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/") abs := filepath.Join(fsRoot, filepath.FromSlash(rel)) if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot { return nil, false } - if fileExists(abs) { - return nil, false - } - return bytes, true + return IsDefaultSpecAbs(fsRoot, abs) } -// IsDefaultMdlSpecAbs is the abs-path-keyed variant of IsDefaultMdlSpec. +// IsDefaultSpecAbs is the abs-path-keyed variant of IsDefaultSpec. // Used by handlers that hold a filesystem path rather than a URL. -// Returns the embedded default bytes + true when absPath is the -// virtual archive//{mdl.table.yaml, mdl.form.yaml} fallback. -func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) { +func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) { if !strings.HasPrefix(absPath, fsRoot+string(filepath.Separator)) && absPath != fsRoot { return nil, false } @@ -104,8 +126,87 @@ func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) { if err != nil { return nil, false } - urlPath := "/" + filepath.ToSlash(rel) - return IsDefaultMdlSpec(fsRoot, urlPath) + rel = filepath.ToSlash(rel) + if rel == "" || rel == "." || strings.HasPrefix(rel, "../") { + return nil, false + } + bytes := classifyDefaultSpec(rel) + if bytes == nil { + return nil, false + } + // Operator file wins if it exists on disk. + if fileExists(absPath) { + return nil, false + } + return bytes, true +} + +// classifyDefaultSpec maps a slash-form path (relative to fsRoot) to +// the matching embedded default-spec bytes, or nil if the path does +// not name one of the recognized virtual fallback files. +func classifyDefaultSpec(rel string) []byte { + parts := strings.Split(rel, "/") + switch len(parts) { + case 5: + // /archive/// + if !strings.EqualFold(parts[1], "archive") { + return nil + } + slot := strings.ToLower(parts[3]) + file := strings.ToLower(parts[4]) + switch slot { + case "mdl": + switch file { + case "table.yaml": + return embeddedDefaultMdlTable + case "form.yaml": + return embeddedDefaultMdlForm + } + case "rsk": + switch file { + case "table.yaml": + return embeddedDefaultRskTable + case "form.yaml": + return embeddedDefaultRskForm + } + } + case 4: + // /archive// — only ssr.form.yaml is virtual. + if !strings.EqualFold(parts[1], "archive") { + return nil + } + if strings.EqualFold(parts[3], "ssr.form.yaml") { + return embeddedDefaultSsrForm + } + case 3: + // // — project-level virtual specs. + slot := strings.ToLower(parts[1]) + file := strings.ToLower(parts[2]) + switch slot { + case "ssr": + switch file { + case "table.yaml": + return embeddedDefaultSsrTable + case "form.yaml": + return embeddedDefaultSsrForm + } + case "mdl": + switch file { + case "table.yaml": + return embeddedDefaultProjectMdlTable + case "form.yaml": + return embeddedDefaultMdlForm + } + case "rsk": + switch file { + case "table.yaml": + return embeddedDefaultProjectRskTable + case "form.yaml": + return embeddedDefaultRskForm + } + } + } + return nil } // isAtArchivePartyLevel reports whether urlPath refers to a file @@ -114,33 +215,19 @@ func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) { func isAtArchivePartyLevel(fsRoot, urlPath string) bool { rel := strings.Trim(filepath.ToSlash(urlPath), "/") parts := strings.Split(rel, "/") - // /archive// = 4 segments if len(parts) != 4 { return false } return strings.EqualFold(parts[1], "archive") } -// isAtArchivePartyMdlLevel reports whether urlPath refers to a file -// directly under /archive//mdl/ (depth-4 directory). -// Used by the default-MDL fallback after the spec/form moved INSIDE -// the rows-dir for self-containment. -func isAtArchivePartyMdlLevel(fsRoot, urlPath string) bool { - rel := strings.Trim(filepath.ToSlash(urlPath), "/") - parts := strings.Split(rel, "/") - // /archive//mdl/ = 5 segments - if len(parts) != 5 { - return false - } - return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl") -} - // TableRequest describes a recognized table-system request. type TableRequest struct { // Name is the table's URL stem (the key declared in .zddc tables). Name string // SpecPath is the absolute filesystem path to the *.table.yaml. - // Validated to exist at recognition time. + // May reference a virtual path when the spec is served from + // embedded defaults. SpecPath string // Dir is the absolute path to the request directory (where the // .zddc declared the table). @@ -149,15 +236,14 @@ type TableRequest struct { // tableRowsRedirect reports the canonical //table.html URL to // redirect to when (urlPath) names a directory that contains a -// table.yaml (or matches the default-MDL fallback). Returns "" when -// no redirect should fire. +// table.yaml (or matches one of the default-spec fallbacks). Returns +// "" when no redirect should fire. // // Recognition reuses RecognizeTableRequest by synthesizing the // equivalent table.html and asking the recognizer whether -// it's a real (or default-MDL) table. Single source of truth for +// it's a real (or default-spec) table. Single source of truth for // validation. func tableRowsRedirect(fsRoot, urlPath string) string { - // urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/". if urlPath == "" || urlPath == "/" { return "" } @@ -169,11 +255,11 @@ func tableRowsRedirect(fsRoot, urlPath string) string { if tr == nil { return "" } - // Default-MDL case (no on-disk table.yaml): follow the slash/no- + // Default-spec case (no on-disk table.yaml): follow the slash/no- // slash convention — slash form serves browse, no-slash serves // tables (handled by the dispatcher). Redirecting here would // override the convention and force the user into the table view - // from any //mdl/ click. + // from any //{mdl,rsk}/ click. if !fileExists(tr.SpecPath) { return "" } @@ -183,15 +269,13 @@ func tableRowsRedirect(fsRoot, urlPath string) string { // RecognizeTableRequest classifies r as a table-system request, or // returns nil if it falls through to other handlers. Discovery is // presence-based and self-contained: a //table.html URL fires -// when /table.yaml exists on disk, or when the default-MDL -// fallback at archive//mdl/ applies. +// when /table.yaml exists on disk, or when one of the default- +// spec fallbacks applies (per-party mdl/rsk under archive//, +// or project-level ssr/mdl/rsk virtual aggregations). // -// The spec, the row-edit form, and all rows live together in . -// Copying elsewhere copies everything needed to re-host the -// table — that's the whole point of the in-dir layout. -// -// The table's "name" is the directory's basename (so the URL -// //mdl/table.html names the "mdl" table, with rows in mdl/). +// The table's "name" is the directory's basename for on-disk and +// per-party-virtual tables (e.g. "mdl"); for project-level virtual +// tables it's the slot name ("ssr", "mdl", "rsk"). // // Methods other than GET return nil — the table is read-only at the // URL level. Writes go through the file API directly. @@ -203,12 +287,10 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest { return nil } - // Strip /table.html — what remains is the rows-dir. rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html") - rel = strings.TrimSuffix(rel, "table.html") // handles "/table.html" at root → "" + rel = strings.TrimSuffix(rel, "table.html") rel = strings.Trim(rel, "/") if rel == "" { - // /table.html at root has no rows-dir to name. return nil } dirAbs := filepath.Join(fsRoot, filepath.FromSlash(rel)) @@ -224,34 +306,49 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest { return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs} } - // Default-MDL virtual-spec fallback at archive//mdl/. The - // spec bytes come from IsDefaultMdlSpec via the static-file - // dispatcher when no on-disk file exists at that path; the rows-dir - // itself doesn't need to exist either (fully virtual archive). - if isAtArchivePartyMdlDir(fsRoot, dirAbs) { - return &TableRequest{Name: "mdl", SpecPath: specAbs, Dir: dirAbs} + // Default-spec fallbacks — the rows-dir itself may not exist on + // disk yet (fully virtual). The static-file dispatcher serves the + // embedded spec bytes from IsDefaultSpecAbs when the client + // fetches /table.yaml client-side. + if slot, ok := classifyVirtualTableDir(fsRoot, dirAbs); ok { + return &TableRequest{Name: slot, SpecPath: specAbs, Dir: dirAbs} } return nil } -// isAtArchivePartyMdlDir reports whether dirAbs is exactly -// //archive//mdl. Used by the default-MDL -// fallback to recognize the virtual rows-dir whether or not it -// exists on disk. -func isAtArchivePartyMdlDir(fsRoot, dirAbs string) bool { +// classifyVirtualTableDir reports whether dirAbs is one of the +// virtual-spec table dirs and returns its slot name ("mdl", "rsk", +// or "ssr"). Recognizes both per-party slots +// (/archive//{mdl,rsk}) and project-level slots +// (/{ssr,mdl,rsk}). +func classifyVirtualTableDir(fsRoot, dirAbs string) (string, bool) { rel, err := filepath.Rel(fsRoot, dirAbs) if err != nil { - return false + return "", false } rel = filepath.ToSlash(rel) if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." { - return false + return "", false } parts := strings.Split(rel, "/") - if len(parts) != 4 { - return false + switch len(parts) { + case 2: + // / + slot := strings.ToLower(parts[1]) + if slot == "ssr" || slot == "mdl" || slot == "rsk" { + return slot, true + } + case 4: + // /archive// + if !strings.EqualFold(parts[1], "archive") { + return "", false + } + slot := strings.ToLower(parts[3]) + if slot == "mdl" || slot == "rsk" { + return slot, true + } } - return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl") + return "", false } // isNotExistError reports whether err indicates a missing file. Local diff --git a/zddc/internal/handler/tablehandler_test.go b/zddc/internal/handler/tablehandler_test.go index 7d421d2..0c49685 100644 --- a/zddc/internal/handler/tablehandler_test.go +++ b/zddc/internal/handler/tablehandler_test.go @@ -147,8 +147,11 @@ func TestRecognizeTableRequest(t *testing.T) { {"DELETE", "/Working/MDL/table.html", true, "", ""}, // No table.yaml in this dir → not a table request. {"GET", "/Working/Other/table.html", true, "", ""}, - // No table.yaml anywhere → not a table request. - {"GET", "/Other/MDL/table.html", true, "", ""}, + // //mdl/ now resolves as the project-level virtual MDL + // rollup (independent of any on-disk file). Recognized as the + // virtual table named "mdl"; the spec bytes are served from + // embedded defaults via IsDefaultSpec on the client fetch. + {"GET", "/Other/mdl/table.html", false, "Other/mdl/table.yaml", "mdl"}, // Random .html → falls through. {"GET", "/index.html", true, "", ""}, // /form.html in the same dir is form territory, not a table. @@ -295,14 +298,14 @@ func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) { } } -func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) { +func TestIsDefaultSpec_MDL_ServesEmbeddedYAML(t *testing.T) { root := t.TempDir() // archive/Acme/ exists but no mdl/table.yaml on disk. if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil { t.Fatal(err) } - bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml") + bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml") if !ok { t.Fatalf("expected fallback to fire") } @@ -310,7 +313,7 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) { t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))]) } - bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/form.yaml") + bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/mdl/form.yaml") if !ok { t.Fatalf("expected form fallback to fire") } @@ -319,7 +322,7 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) { } } -func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) { +func TestIsDefaultSpec_MDL_OperatorFileWins(t *testing.T) { root := t.TempDir() mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl") if err := os.MkdirAll(mdlDir, 0o755); err != nil { @@ -328,12 +331,12 @@ func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) { if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil { t.Fatal(err) } - if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok { + if _, ok := IsDefaultSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok { t.Errorf("operator file should win over embedded fallback") } } -func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) { +func TestIsDefaultSpec_MDL_OnlyAtArchivePartyLevel(t *testing.T) { root := t.TempDir() cases := []string{ "/Project/working/mdl/table.yaml", @@ -341,9 +344,100 @@ func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) { "/Project/archive/Acme/sub/mdl/table.yaml", } for _, p := range cases { - if _, ok := IsDefaultMdlSpec(root, p); ok { + if _, ok := IsDefaultSpec(root, p); ok { t.Errorf("path %q should NOT trigger default fallback", p) } } } +// --- RSK + SSR + project-rollup default-spec recognition -------------------- + +func TestRecognizeTableRequest_DefaultRskAtArchiveParty(t *testing.T) { + _, do := archivePartyTestSetup(t, "") + rec := do(http.MethodGet, "/Project/archive/Acme/rsk/table.html", "alice@example.com") + if rec.Code != http.StatusOK { + t.Fatalf("default rsk recognition: want 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestRecognizeTableRequest_ProjectVirtualTables(t *testing.T) { + _, do := archivePartyTestSetup(t, "") + for _, slot := range []string{"ssr", "mdl", "rsk"} { + rec := do(http.MethodGet, "/Project/"+slot+"/table.html", "alice@example.com") + if rec.Code != http.StatusOK { + t.Errorf("project-level virtual table %q: want 200, got %d", slot, rec.Code) + } + } +} + +func TestIsDefaultSpec_RSK_ServesEmbeddedYAML(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil { + t.Fatal(err) + } + bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/rsk/table.yaml") + if !ok { + t.Fatalf("expected RSK table fallback to fire") + } + if !strings.Contains(string(bts), "Risk Register") { + t.Errorf("default RSK table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))]) + } + bts, ok = IsDefaultSpec(root, "/Project/archive/Acme/rsk/form.yaml") + if !ok { + t.Fatalf("expected RSK form fallback to fire") + } + if !strings.Contains(string(bts), "Risk") { + t.Error("default RSK form spec missing expected title") + } +} + +func TestIsDefaultSpec_SSR_PerParty(t *testing.T) { + root := t.TempDir() + // archive//ssr.form.yaml — per-party SSR schema. + bts, ok := IsDefaultSpec(root, "/Project/archive/Acme/ssr.form.yaml") + if !ok { + t.Fatalf("expected per-party SSR schema fallback to fire") + } + if !strings.Contains(string(bts), "Supplier") { + t.Errorf("per-party SSR schema missing expected header") + } +} + +func TestIsDefaultSpec_ProjectLevel(t *testing.T) { + root := t.TempDir() + cases := []struct { + url string + contains string + }{ + {"/Project/ssr/table.yaml", "Supplier"}, + {"/Project/ssr/form.yaml", "Supplier"}, + {"/Project/mdl/table.yaml", "Project Deliverables"}, + {"/Project/mdl/form.yaml", "Deliverable"}, + {"/Project/rsk/table.yaml", "Project Risk Register"}, + {"/Project/rsk/form.yaml", "Risk"}, + } + for _, tc := range cases { + bts, ok := IsDefaultSpec(root, tc.url) + if !ok { + t.Errorf("%s: expected fallback to fire", tc.url) + continue + } + if !strings.Contains(string(bts), tc.contains) { + t.Errorf("%s: body missing %q; got %q…", tc.url, tc.contains, string(bts)[:min(120, len(bts))]) + } + } +} + +func TestIsDefaultSpec_ProjectLevel_OperatorOverrides(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "Project", "ssr"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Project", "ssr", "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, ok := IsDefaultSpec(root, "/Project/ssr/table.yaml"); ok { + t.Errorf("operator file should win at /Project/ssr/table.yaml") + } +} + diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index f8d18a9..dbb59ec 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-18 22:38:21 · 85e6eb1-dirty + v0.0.17-alpha · 2026-05-19 02:25:26 · da4754b-dirty
@@ -3548,6 +3548,11 @@ body.is-elevated::after { description: spec.description, columns: spec.columns, defaults: spec.defaults, + // addable defaults to true; tables can opt out with + // `addable: false` (used by project-rollup MDL/RSK where the + // party affiliation of a new row is ambiguous — add at the + // per-party path instead). + addable: spec.addable !== false, rowSchema: rowSchema, rows: rows }; @@ -6231,7 +6236,11 @@ body.is-elevated::after { if (addRowBtn) { const onHttp = location.protocol === 'http:' || location.protocol === 'https:'; const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0; - if (onHttp && hasCols) { + // ctx.addable === false suppresses the affordance entirely. + // Used by project-rollup tables where the row's party + // affiliation is ambiguous (add at the per-party path). + const allowAdd = ctx.addable !== false; + if (onHttp && hasCols && allowAdd) { addRowBtn.hidden = false; addRowBtn.removeAttribute('href'); addRowBtn.setAttribute('role', 'button'); diff --git a/zddc/internal/handler/virtualviewhandler.go b/zddc/internal/handler/virtualviewhandler.go new file mode 100644 index 0000000..198d2e2 --- /dev/null +++ b/zddc/internal/handler/virtualviewhandler.go @@ -0,0 +1,101 @@ +// Package handler — virtualviewhandler.go: GET dispatch for SSR row + +// MDL/RSK rollup row URLs. +// +// These URLs live in the project-level virtual folders (/ssr, +// /mdl, /rsk) and rewrite to canonical files inside +// /archive//. 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: ` 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: ` 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//ssr.yaml bytes +// with `name: ` injected. If no canonical file exists yet, +// returns `name: \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: ` 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) +} diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 472d4f7..efc2ac2 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -153,6 +153,13 @@ paths: # tables tool serves it from the embedded default # spec even when the on-disk folder doesn't exist. virtual: true + rsk: + default_tool: tables + available_tools: [tables] + # Risk register — same virtual-by-convention pattern + # as mdl/. Embedded default-rsk spec backs it when no + # operator override is on disk. + virtual: true incoming: # incoming/ is the COUNTERPARTY's drop zone. The flow: # 1. the other party's document controller uploads @@ -254,3 +261,29 @@ paths: auto_own: true drop_target: true admins: [document_controller] + # Project-level aggregation tables. All three are virtual: the + # folder doesn't exist on disk; the server synthesizes listings + # by walking archive/*/ at request time. ACL on each synthetic + # row is evaluated against the canonical archive// path, + # so party owners can edit their own rows and non-owners see + # them read-only. + ssr: + default_tool: tables + available_tools: [tables] + # SSR aggregates one row per party folder; the row's backing + # file is archive//ssr.yaml. + Add row in this view + # creates a new party folder. + virtual: true + mdl: + default_tool: tables + available_tools: [tables] + # Project-rollup of every archive//mdl/ row. Read + + # edit; + Add row is disabled because party affiliation is + # ambiguous here (add at the per-party path instead). + virtual: true + rsk: + default_tool: tables + available_tools: [tables] + # Project-rollup of every archive//rsk/ row. Same + # semantics as the mdl rollup. + virtual: true diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index deb7929..40c71bc 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -184,13 +184,16 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) { } // TestChildrenDeclaredAt_FromEmbeddedConvention — at a project -// root, the four canonical children should be enumerated. +// root, the canonical children should be enumerated: the four +// physical folders (archive, working, staging, reviewing) plus the +// three project-level virtual aggregator slots (ssr, mdl, rsk). func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) { resetCache() root := t.TempDir() got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X")) want := map[string]bool{ "archive": true, "working": true, "staging": true, "reviewing": true, + "ssr": true, "mdl": true, "rsk": true, } if len(got) != len(want) { t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)