fix(reviewing): case-insensitive on-disk lookup for archive/+staging/+

party/{received,issued}

The synthetic test fixture and many real deployments use PascalCase
folder names (Archive/, PartyB/, Received/, Issued/, Staging/). The
aggregator was hard-coding lowercase joins, which on case-sensitive
filesystems (Linux ext4) meant os.ReadDir returned NotExist and the
listing was empty even when the data was present.

Use zddc.ResolveCanonical to find the on-disk casing for each
canonical segment (archive/, staging/, then per-party received/ and
issued/), and emit URLs with the resolved casing so the dispatcher's
URL canonicalisation is a no-op pass-through.

The case-insensitive lookup was already used elsewhere (file API's
mkdir, tree.go's virtualUserHomeEntry); reviewing/ now matches that
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 21:38:50 -05:00
parent 002e034119
commit d1a5a14132

View file

@ -83,22 +83,35 @@ func computePending(ctx context.Context, decider policy.Decider,
fsRoot, project, email string) ([]pendingSubmittal, error) { fsRoot, project, email string) ([]pendingSubmittal, error) {
projectAbs := filepath.Join(fsRoot, project) projectAbs := filepath.Join(fsRoot, project)
archiveAbs := filepath.Join(projectAbs, "archive")
stagingAbs := filepath.Join(projectAbs, "staging") // Resolve the canonical folder names to whatever case is present
// on disk (deployments may use Archive/ Received/ Issued/ Staging/
// PascalCase). Empty string means no case variant exists — treated
// as missing (empty contribution to the join).
archiveOnDisk, _ := zddc.ResolveCanonical(projectAbs, "archive")
stagingOnDisk, _ := zddc.ResolveCanonical(projectAbs, "staging")
// Index staging by tracking → folder name. // Index staging by tracking → folder name.
stagedByTracking := map[string]string{} stagedByTracking := map[string]string{}
if entries, err := os.ReadDir(stagingAbs); err == nil { var stagingAbs string
for _, e := range entries { if stagingOnDisk != "" {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { stagingAbs = filepath.Join(projectAbs, stagingOnDisk)
continue if entries, err := os.ReadDir(stagingAbs); err == nil {
} for _, e := range entries {
if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok { if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
stagedByTracking[tracking] = e.Name() continue
}
if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
stagedByTracking[tracking] = e.Name()
}
} }
} }
} }
if archiveOnDisk == "" {
return nil, nil
}
archiveAbs := filepath.Join(projectAbs, archiveOnDisk)
parties, err := os.ReadDir(archiveAbs) parties, err := os.ReadDir(archiveAbs)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@ -114,7 +127,13 @@ func computePending(ctx context.Context, decider policy.Decider,
} }
party := p.Name() party := p.Name()
partyAbs := filepath.Join(archiveAbs, party) partyAbs := filepath.Join(archiveAbs, party)
receivedAbs := filepath.Join(partyAbs, "received") // Per-party canonical folder resolution (Received/ vs received/).
receivedSeg, _ := zddc.ResolveCanonical(partyAbs, "received")
issuedSeg, _ := zddc.ResolveCanonical(partyAbs, "issued")
if receivedSeg == "" {
continue // party with no received/ at all → nothing to review
}
receivedAbs := filepath.Join(partyAbs, receivedSeg)
// ACL: skip parties whose received/ subtree the caller can't read. // ACL: skip parties whose received/ subtree the caller can't read.
// Filtering at the party level is cheaper than per-entry and matches // Filtering at the party level is cheaper than per-entry and matches
@ -123,7 +142,10 @@ func computePending(ctx context.Context, decider policy.Decider,
if err != nil { if err != nil {
continue continue
} }
receivedURLPrefix := "/" + project + "/archive/" + party + "/received/" // URL prefix preserves the on-disk casing so links resolve
// directly against the canonicalisation done by the URL
// dispatcher (no additional case-fold round-trip needed).
receivedURLPrefix := "/" + project + "/" + archiveOnDisk + "/" + party + "/" + receivedSeg + "/"
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, receivedURLPrefix); !allowed { if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, receivedURLPrefix); !allowed {
continue continue
} }
@ -132,13 +154,15 @@ func computePending(ctx context.Context, decider policy.Decider,
// is WORM-readable to anyone with party access by design, and // is WORM-readable to anyone with party access by design, and
// we just need the set membership for matching). // we just need the set membership for matching).
issuedTrackings := map[string]bool{} issuedTrackings := map[string]bool{}
if entries, err := os.ReadDir(filepath.Join(partyAbs, "issued")); err == nil { if issuedSeg != "" {
for _, e := range entries { if entries, err := os.ReadDir(filepath.Join(partyAbs, issuedSeg)); err == nil {
if !e.IsDir() { for _, e := range entries {
continue if !e.IsDir() {
} continue
if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok { }
issuedTrackings[tracking] = true if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
issuedTrackings[tracking] = true
}
} }
} }
} }
@ -172,7 +196,7 @@ func computePending(ctx context.Context, decider policy.Decider,
lastModified: modTime, lastModified: modTime,
} }
if stagedFolder, hasDraft := stagedByTracking[tracking]; hasDraft { if stagedFolder, hasDraft := stagedByTracking[tracking]; hasDraft {
sub.stagedURL = "/" + project + "/staging/" + url.PathEscape(stagedFolder) + "/" sub.stagedURL = "/" + project + "/" + stagingOnDisk + "/" + url.PathEscape(stagedFolder) + "/"
if stagedInfo, err := os.Stat(filepath.Join(stagingAbs, stagedFolder)); err == nil { if stagedInfo, err := os.Stat(filepath.Join(stagingAbs, stagedFolder)); err == nil {
if stagedInfo.ModTime().After(modTime) { if stagedInfo.ModTime().After(modTime) {
sub.lastModified = stagedInfo.ModTime() sub.lastModified = stagedInfo.ModTime()