diff --git a/zddc/internal/handler/reviewinghandler.go b/zddc/internal/handler/reviewinghandler.go index e59c824..2b914bb 100644 --- a/zddc/internal/handler/reviewinghandler.go +++ b/zddc/internal/handler/reviewinghandler.go @@ -83,22 +83,35 @@ 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") + + // 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. 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() + var stagingAbs string + if stagingOnDisk != "" { + stagingAbs = filepath.Join(projectAbs, stagingOnDisk) + 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() + } } } } + if archiveOnDisk == "" { + return nil, nil + } + archiveAbs := filepath.Join(projectAbs, archiveOnDisk) parties, err := os.ReadDir(archiveAbs) if err != nil { if os.IsNotExist(err) { @@ -114,7 +127,13 @@ func computePending(ctx context.Context, decider policy.Decider, } party := p.Name() 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. // 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 { 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 { continue } @@ -132,13 +154,15 @@ func computePending(ctx context.Context, decider policy.Decider, // 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 + if issuedSeg != "" { + if entries, err := os.ReadDir(filepath.Join(partyAbs, issuedSeg)); err == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + 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, } 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.ModTime().After(modTime) { sub.lastModified = stagedInfo.ModTime()