Implements the reviewing/ aggregator described in the saved
project memory (~/.claude/projects/-home-user-src-zddc/memory/
project_reviewing_folder_design.md). reviewing/ stays in
VirtualOnlyCanonicalNames — never materialised on disk — and is
served as a join over archive/<party>/received/, archive/<party>/
issued/, and staging/, recomputed on every read.
Two depths, both trailing-slash:
GET <project>/reviewing/?json=1
→ array of virtual <tracking>/ entries, one per submittal in
archive/<party>/received/ that doesn't yet have a matching
archive/<party>/issued/ entry. Sorted by tracking. URLs stay
under reviewing/ so the user can drill into the per-submittal
view. ACL: per-party, filtered like fs.ListDirectory.
GET <project>/reviewing/<tracking>/?json=1
→ array of two virtual entries, received/ + staged/, with
canonical URLs pointing back to archive/<party>/received/...
and staging/... respectively. staged/ is omitted when no
response draft exists yet.
When the response moves staging/ → archive/<party>/issued/, the
entry vanishes from depth-0 on the next listing. No mutation of
the reviewing/ subtree itself; pure join, recomputed on read.
Front-end at <project>/reviewing[/<tracking>/] is mdedit (per
user request). DefaultAppAt + AppAvailableAt extended to recognise
"reviewing" as a canonical mdedit-bearing folder. The polyfill in
shared/zddc-source.js is updated to follow listing entries' explicit
url field when present (absolute or root-relative) — that's how
mdedit's tree follows the depth-1 received/ + staged/ links into
the canonical archive/staging subtrees.
Dispatcher routing in zddc-server/main.go:
- GET <project>/reviewing/[<tracking>/] with Accept: json
→ ServeReviewing
- GET <project>/reviewing/[<tracking>/] with Accept: html
→ mdedit (rooted at the virtual path; polyfill fetches the
JSON listing on its own)
- GET <project>/reviewing (no slash) → mdedit (via DefaultAppAt)
- GET <project>/reviewing/<tracking> (no slash) → 301 to slash form
Tests:
- handler/reviewinghandler_test.go (6 cases): IsReviewingPath
classification + ServeReviewing depth-0/depth-1 with and without
staged drafts + 404 on unknown tracking + empty when archive/ is
absent.
- apps/availability_test.go updated: reviewing/ now expects mdedit
rather than "" (no default).
- cmd/zddc-server/main_test.go: TestDispatchEmptyCanonicalProjectFolders
extended to assert reviewing → mdedit at the no-slash form;
older "no-slash/reviewing → 301" test removed.
Future work (not in this commit): write translation. Editing a file
under reviewing/<tracking>/staged/<f>.md works today because the
polyfill rewrites to /<project>/staging/<response>/<f>.md before
fetching — the user's URL bar moves to the canonical path on click.
A virtual-filesystem mode where the URL bar stays under reviewing/
throughout would require server-side write rewriting (translate
PUT/DELETE on reviewing/.../staged/... into the canonical staging/
path). Not needed for the MVP — links in mdedit's tree work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
264 lines
8.3 KiB
Go
264 lines
8.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// IsReviewingPath classifies a URL as a reviewing-aggregator path and
|
|
// extracts (project, tracking). The aggregator is a virtual view at:
|
|
//
|
|
// <project>/reviewing/ → depth 0: list pending submittals
|
|
// <project>/reviewing/<tracking>/ → depth 1: list received/ + staged/
|
|
//
|
|
// Anything deeper than depth 1 returns ok=false; the depth-1 listing
|
|
// emits canonical URLs (under archive/ and staging/) so navigation past
|
|
// that point goes through the regular file-tree handlers, not back into
|
|
// the virtual reviewing/ subtree.
|
|
//
|
|
// Trailing slash on either depth is required and tolerated. Match on
|
|
// "reviewing" is case-insensitive.
|
|
func IsReviewingPath(urlPath string) (project, tracking string, ok bool) {
|
|
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
|
if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") {
|
|
return "", "", false
|
|
}
|
|
switch len(parts) {
|
|
case 2:
|
|
return parts[0], "", true
|
|
case 3:
|
|
return parts[0], parts[2], true
|
|
default:
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
// pendingSubmittal is one row of the aggregator's view: a submittal in
|
|
// archive/<party>/received/ that doesn't yet have a matching entry in
|
|
// archive/<party>/issued/, optionally paired with an in-progress
|
|
// response folder under staging/.
|
|
type pendingSubmittal struct {
|
|
tracking string // canonical tracking number, e.g. "123456-ST-SUB-0026"
|
|
party string // party folder name, e.g. "Acme"
|
|
receivedURL string // /<project>/archive/<party>/received/<folder>/
|
|
stagedURL string // /<project>/staging/<folder>/ or "" if no draft yet
|
|
lastModified time.Time // newer of the two folders' mtimes
|
|
}
|
|
|
|
// computePending walks the project's archive/ and staging/ subtrees to
|
|
// build the virtual reviewing-aggregator view.
|
|
//
|
|
// Algorithm:
|
|
//
|
|
// 1. Index staging/<folder>/ by tracking number.
|
|
// 2. For each party under archive/<party>/:
|
|
// a. Index archive/<party>/issued/ by tracking number.
|
|
// b. For each archive/<party>/received/<folder>:
|
|
// - skip folders that don't parse as transmittal folders.
|
|
// - skip if tracking already in issued (response complete).
|
|
// - emit a pendingSubmittal pointing at the canonical received
|
|
// URL and (if found) the matching staging URL.
|
|
//
|
|
// ACL: per-party. The caller's email + decider are consulted on the
|
|
// archive/<party>/received/ subtree before reading its contents — a
|
|
// party the caller can't see at upstream is omitted entirely (no info
|
|
// leak via tracking-number listing).
|
|
//
|
|
// Missing intermediate folders (archive/, party/issued/, staging/) are
|
|
// not errors; they just produce empty intermediate sets. This matches
|
|
// the lazy-instantiation pattern of the canonical project folders.
|
|
func computePending(ctx context.Context, decider policy.Decider,
|
|
fsRoot, project, email string) ([]pendingSubmittal, error) {
|
|
|
|
projectAbs := filepath.Join(fsRoot, project)
|
|
archiveAbs := filepath.Join(projectAbs, "archive")
|
|
stagingAbs := filepath.Join(projectAbs, "staging")
|
|
|
|
// Index staging by tracking → folder name.
|
|
stagedByTracking := map[string]string{}
|
|
if entries, err := os.ReadDir(stagingAbs); err == nil {
|
|
for _, e := range entries {
|
|
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
|
|
stagedByTracking[tracking] = e.Name()
|
|
}
|
|
}
|
|
}
|
|
|
|
parties, err := os.ReadDir(archiveAbs)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var result []pendingSubmittal
|
|
for _, p := range parties {
|
|
if !p.IsDir() || strings.HasPrefix(p.Name(), ".") {
|
|
continue
|
|
}
|
|
party := p.Name()
|
|
partyAbs := filepath.Join(archiveAbs, party)
|
|
receivedAbs := filepath.Join(partyAbs, "received")
|
|
|
|
// ACL: skip parties whose received/ subtree the caller can't read.
|
|
// Filtering at the party level is cheaper than per-entry and matches
|
|
// fs.ListDirectory's omit-denied-subdirs convention.
|
|
chain, err := zddc.EffectivePolicy(fsRoot, receivedAbs)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
receivedURLPrefix := "/" + project + "/archive/" + party + "/received/"
|
|
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, receivedURLPrefix); !allowed {
|
|
continue
|
|
}
|
|
|
|
// Index this party's issued/ trackings (no ACL filter — issued/
|
|
// is WORM-readable to anyone with party access by design, and
|
|
// we just need the set membership for matching).
|
|
issuedTrackings := map[string]bool{}
|
|
if entries, err := os.ReadDir(filepath.Join(partyAbs, "issued")); err == nil {
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
|
|
issuedTrackings[tracking] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
receivedEntries, err := os.ReadDir(receivedAbs)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, e := range receivedEntries {
|
|
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
_, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name())
|
|
if !ok {
|
|
continue
|
|
}
|
|
if issuedTrackings[tracking] {
|
|
continue // response complete; not pending
|
|
}
|
|
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
modTime := info.ModTime()
|
|
|
|
sub := pendingSubmittal{
|
|
tracking: tracking,
|
|
party: party,
|
|
receivedURL: receivedURLPrefix + url.PathEscape(e.Name()) + "/",
|
|
lastModified: modTime,
|
|
}
|
|
if stagedFolder, hasDraft := stagedByTracking[tracking]; hasDraft {
|
|
sub.stagedURL = "/" + project + "/staging/" + url.PathEscape(stagedFolder) + "/"
|
|
if stagedInfo, err := os.Stat(filepath.Join(stagingAbs, stagedFolder)); err == nil {
|
|
if stagedInfo.ModTime().After(modTime) {
|
|
sub.lastModified = stagedInfo.ModTime()
|
|
}
|
|
}
|
|
}
|
|
result = append(result, sub)
|
|
}
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].tracking < result[j].tracking
|
|
})
|
|
return result, nil
|
|
}
|
|
|
|
// ServeReviewing emits the aggregator JSON listing for either depth 0
|
|
// (project's full pending list) or depth 1 (one submittal's
|
|
// received/ + staged/ pair). The HTML branch is handled separately by
|
|
// the apps subsystem (mdedit served at the URL); only requests that
|
|
// accept JSON reach here.
|
|
func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
|
project, tracking string) {
|
|
|
|
pending, err := computePending(r.Context(), DeciderFromContext(r),
|
|
cfg.Root, project, EmailFromContext(r))
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var entries []listing.FileInfo
|
|
switch tracking {
|
|
case "":
|
|
// Depth 0: list pending submittals as virtual <tracking>/ folders.
|
|
// The URLs stay under reviewing/ so the user can drill into a
|
|
// per-submittal view.
|
|
urlPrefix := "/" + project + "/reviewing/"
|
|
for _, s := range pending {
|
|
entries = append(entries, listing.FileInfo{
|
|
Name: s.tracking + "/",
|
|
URL: urlPrefix + url.PathEscape(s.tracking) + "/",
|
|
ModTime: s.lastModified,
|
|
IsDir: true,
|
|
Virtual: true,
|
|
})
|
|
}
|
|
default:
|
|
// Depth 1: find the matching pending entry; emit received/ +
|
|
// staged/ pointing at canonical archive/staging URLs. Clients
|
|
// using the polyfill follow these URLs out of the virtual
|
|
// subtree into the real file paths underneath.
|
|
var match *pendingSubmittal
|
|
for i := range pending {
|
|
if pending[i].tracking == tracking {
|
|
match = &pending[i]
|
|
break
|
|
}
|
|
}
|
|
if match == nil {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
entries = append(entries, listing.FileInfo{
|
|
Name: "received/",
|
|
URL: match.receivedURL,
|
|
ModTime: match.lastModified,
|
|
IsDir: true,
|
|
Virtual: true,
|
|
})
|
|
if match.stagedURL != "" {
|
|
entries = append(entries, listing.FileInfo{
|
|
Name: "staged/",
|
|
URL: match.stagedURL,
|
|
ModTime: match.lastModified,
|
|
IsDir: true,
|
|
Virtual: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
if entries == nil {
|
|
entries = []listing.FileInfo{}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store") // virtual; recompute every time
|
|
w.Header().Set("X-ZDDC-Source", "reviewing-aggregator")
|
|
_ = json.NewEncoder(w).Encode(entries)
|
|
}
|