Files
paliad/internal/services/submission_templates.go
mAi 3677c81fbe feat(submissions): t-paliad-215 Slice 1 — template registry + variable bag
TemplateRegistry (services/submission_templates.go) walks the
m-locked Q4 fallback chain — templates/{FIRM_NAME}/{code}.docx →
templates/_base/{code}.docx → templates/_base/{family}.docx →
templates/_base/_skeleton.docx — against the Gitea repo
HL/mWorkRepo. SHA-cache + 5-min refresh check, identical pattern to
internal/handlers/files.go's HL Patents Style proxy. Distinguishes
"no template" (chain fallthrough) from "Gitea down" so the handler
can render different UI for each.

SubmissionVarsService (services/submission_vars.go) assembles the
~30-placeholder bag from project + parties + rule + next-deadline +
user + firm + today. Locale-aware long-date forms (DE + EN) and a
legal_source pretty-printer that rewrites DE.ZPO.276.1 → "§ 276 Abs.
1 ZPO" / "Section 276(1) ZPO" for the prefixes the 254-rule corpus
uses today. Unknown prefixes pass through unchanged.

Visibility inherits from ProjectService.GetByID
(paliad.can_see_project) — unauthorised callers get the same
ErrNotVisible that every project surface returns.
2026-05-19 13:42:51 +02:00

443 lines
14 KiB
Go

package services
// Submission template registry — Gitea-backed .docx template loader for
// the submission generator (t-paliad-215, design doc
// docs/design-submission-generator-2026-05-19.md §5).
//
// Layout in mWorkRepo:
//
// templates/{FIRM_NAME}/{submission_code}.docx firm-specific override
// templates/_base/{submission_code}.docx cross-firm baseline
// templates/_base/{family}.docx proceeding-family fallback
// templates/_base/_skeleton.docx ultra-generic fallback
//
// Lookup is first-match-wins down the chain; this is the m-locked Q4
// decision. Templates fetched via Gitea's raw URL endpoint, cached
// in-process with a 5-minute SHA refresh check — identical pattern to
// the HL Patents Style proxy in internal/handlers/files.go (which the
// design doc §1 verified is in production and works).
//
// Slice 1 ships one template at templates/_base/de.inf.lg.erwidg.docx
// (committed to HL/mWorkRepo at SHA 7f97b7f9, the bootstrap demo
// authored by the engine for end-to-end testing — HLC ships the
// polished version per §14 follow-up).
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
templatesGiteaBaseURL = "https://mgit.msbls.de"
templatesGiteaRepoOwn = "HL"
templatesGiteaRepoName = "mWorkRepo"
templatesGiteaBranch = "main"
templatesCheckInterval = 5 * time.Minute
templatesSkeleton = "_skeleton"
)
// ErrNoTemplate is returned when no template resolves anywhere in the
// fallback chain (firm/code → base/code → base/family → skeleton).
// Caller maps to 503 + a clear UI hint.
var ErrNoTemplate = errors.New("submission template: no template resolved in fallback chain")
// ErrTemplateUpstream wraps Gitea-side failures (network, 5xx).
// Distinct from ErrNoTemplate so the handler can render different UI:
// "no template configured" vs "template repo unreachable".
var ErrTemplateUpstream = errors.New("submission template: upstream Gitea unreachable")
// ResolvedTemplate is the result of a fallback-chain lookup: the
// template bytes plus the metadata the audit row + UI need.
type ResolvedTemplate struct {
// Path is the Gitea-relative path that resolved (e.g.
// "templates/HLC/de.inf.lg.erwidg.docx"). Persisted in the
// system_audit_log row so an admin can trace which template was
// used for a given generation.
Path string
// SHA is the commit SHA the template was fetched at. Pinning this
// lets audit consumers reproduce the exact bytes that went into
// the lawyer's download.
SHA string
// FirmTier reports which level of the fallback chain fired:
// "firm", "base_code", "base_family", or "skeleton". Useful for
// the variable-contract sidebar (Slice 3) and for ops monitoring
// of how often each firm is actually overriding.
FirmTier string
// Bytes is the .docx content; only populated for callers that
// need to render (i.e. SubmissionRenderer.Render). Resolve()
// returns it populated; Probe() leaves it nil.
Bytes []byte
}
// templateCacheEntry mirrors the per-file cache shape used by
// internal/handlers/files.go. Each cached entry tracks its bytes, the
// commit SHA, the last upstream check, and a checking flag so two
// concurrent refresh goroutines don't double-fetch.
type templateCacheEntry struct {
mu sync.RWMutex
data []byte
sha string
lastChecked time.Time
checking bool
missing bool // true when Gitea returned 404 — short-circuits subsequent lookups
}
// TemplateRegistry resolves submission templates from Gitea using the
// fallback chain. Process-wide cache; single-replica deployment (per
// docs/design-submission-generator-2026-05-19.md §1) makes in-process
// caching sufficient — a future multi-replica rollout would swap this
// for a shared cache. Same trade-off the HL Patents Style proxy makes.
type TemplateRegistry struct {
cache map[string]*templateCacheEntry
cacheMu sync.Mutex
giteaToken string
httpClient *http.Client
firmName string
}
// NewTemplateRegistry constructs the registry. firmName is read once
// at process start from internal/branding.Name so a runtime FIRM_NAME
// rebrand cuts in on the next deploy, not mid-request.
func NewTemplateRegistry(giteaToken, firmName string) *TemplateRegistry {
return &TemplateRegistry{
cache: make(map[string]*templateCacheEntry),
giteaToken: giteaToken,
firmName: firmName,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// HasTemplate reports whether any template resolves for the given
// submission code, without fetching the bytes. Used by the
// SubmissionsPanel to decide which "Generate" buttons to enable.
//
// Cheap path: walks the same fallback chain as Resolve, but stops at
// the SHA-probe step (Gitea's contents endpoint, single round-trip per
// candidate). The probe results land in the same cache as Resolve so a
// subsequent Resolve call reuses the SHA.
func (r *TemplateRegistry) HasTemplate(ctx context.Context, submissionCode string) bool {
for _, candidate := range r.candidates(submissionCode) {
if r.probe(ctx, candidate) {
return true
}
}
return false
}
// Resolve walks the fallback chain and returns the first template that
// fetches successfully, with bytes loaded. Returns ErrNoTemplate when
// no candidate (including the ultra-generic skeleton) resolves.
func (r *TemplateRegistry) Resolve(ctx context.Context, submissionCode string) (*ResolvedTemplate, error) {
candidates := r.candidates(submissionCode)
tiers := r.tiers(submissionCode)
if len(candidates) != len(tiers) {
return nil, fmt.Errorf("template registry: candidate/tier mismatch (%d vs %d)", len(candidates), len(tiers))
}
for i, candidate := range candidates {
entry := r.cacheGet(candidate)
entry.mu.RLock()
hasData := !entry.missing && len(entry.data) > 0
needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
isMissing := entry.missing
entry.mu.RUnlock()
if isMissing && !needsCheck {
continue
}
if !hasData {
if err := r.fetchInto(ctx, candidate, entry); err != nil {
if errors.Is(err, errTemplate404) {
continue
}
return nil, fmt.Errorf("%w: %v", ErrTemplateUpstream, err)
}
} else if needsCheck {
go r.refresh(context.Background(), candidate, entry)
}
entry.mu.RLock()
out := &ResolvedTemplate{
Path: candidate,
SHA: entry.sha,
FirmTier: tiers[i],
Bytes: append([]byte(nil), entry.data...),
}
entry.mu.RUnlock()
return out, nil
}
return nil, ErrNoTemplate
}
// candidates returns the ordered Gitea-relative paths the registry
// walks for the given submission code. The order is the m-locked Q4
// decision: firm → base/code → base/family → skeleton.
func (r *TemplateRegistry) candidates(submissionCode string) []string {
family := familyOf(submissionCode)
out := []string{
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
}
if family != "" && family != submissionCode {
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
}
out = append(out, fmt.Sprintf("templates/_base/%s.docx", templatesSkeleton))
return out
}
// tiers labels each candidate with its fallback tier. Order is locked
// to candidates(); both functions evolve together.
func (r *TemplateRegistry) tiers(submissionCode string) []string {
family := familyOf(submissionCode)
out := []string{"firm", "base_code"}
if family != "" && family != submissionCode {
out = append(out, "base_family")
}
out = append(out, "skeleton")
return out
}
// familyOf extracts the proceeding-family prefix from a submission
// code. The convention (docs/design-proceeding-code-taxonomy-2026-05-18.md)
// is jurisdiction.substantive.forum.submission, so the family is the
// first three dot-segments.
//
// de.inf.lg.erwidg → de.inf.lg
// upc.inf.cfi.soc → upc.inf.cfi
// dpma.opp.dpma → "" (only three segments — no submission suffix)
//
// Returns "" when the code doesn't carry a submission segment (no
// family-level fallback is meaningful).
func familyOf(submissionCode string) string {
parts := strings.Split(submissionCode, ".")
if len(parts) < 4 {
return ""
}
return strings.Join(parts[:3], ".")
}
// cacheGet returns the cache entry for a Gitea path, creating an empty
// entry on first lookup.
func (r *TemplateRegistry) cacheGet(path string) *templateCacheEntry {
r.cacheMu.Lock()
defer r.cacheMu.Unlock()
entry, ok := r.cache[path]
if !ok {
entry = &templateCacheEntry{}
r.cache[path] = entry
}
return entry
}
// errTemplate404 is an internal sentinel: candidate doesn't exist in
// Gitea, walk the chain. Distinguished from network/5xx errors so the
// registry doesn't wrap every fallback miss as ErrTemplateUpstream.
var errTemplate404 = errors.New("template not found in gitea")
// fetchInto downloads a candidate and populates the cache entry. On
// 404 it marks the entry missing so subsequent lookups short-circuit
// without hitting the network.
func (r *TemplateRegistry) fetchInto(ctx context.Context, path string, entry *templateCacheEntry) error {
sha, err := r.giteaSHA(ctx, path)
if err != nil {
if errors.Is(err, errTemplate404) {
entry.mu.Lock()
entry.missing = true
entry.lastChecked = time.Now()
entry.mu.Unlock()
}
return err
}
data, err := r.giteaDownload(ctx, path)
if err != nil {
return err
}
entry.mu.Lock()
entry.data = data
entry.sha = sha
entry.lastChecked = time.Now()
entry.missing = false
entry.mu.Unlock()
return nil
}
// refresh runs in the background after a stale-but-present cache hit.
// SHA-checks the candidate; re-downloads on change. Mirrors the same
// goroutine pattern as internal/handlers/files.go.
func (r *TemplateRegistry) refresh(ctx context.Context, path string, entry *templateCacheEntry) {
entry.mu.Lock()
if entry.checking {
entry.mu.Unlock()
return
}
entry.checking = true
entry.mu.Unlock()
defer func() {
entry.mu.Lock()
entry.checking = false
entry.mu.Unlock()
}()
latestSHA, err := r.giteaSHA(ctx, path)
if err != nil {
log.Printf("submission template: SHA check for %s failed: %v", path, err)
entry.mu.Lock()
entry.lastChecked = time.Now()
entry.mu.Unlock()
return
}
entry.mu.RLock()
unchanged := latestSHA == entry.sha && entry.sha != ""
entry.mu.RUnlock()
if unchanged {
entry.mu.Lock()
entry.lastChecked = time.Now()
entry.mu.Unlock()
return
}
data, err := r.giteaDownload(ctx, path)
if err != nil {
log.Printf("submission template: download %s failed: %v", path, err)
entry.mu.Lock()
entry.lastChecked = time.Now()
entry.mu.Unlock()
return
}
entry.mu.Lock()
entry.data = data
entry.sha = latestSHA
entry.lastChecked = time.Now()
entry.mu.Unlock()
log.Printf("submission template: updated %s (SHA: %.8s)", path, latestSHA)
}
// probe is the cheap existence-check used by HasTemplate. Reuses the
// cache but only fetches the SHA (not the bytes), so the
// SubmissionsPanel's per-row HasTemplate calls don't pull a megabyte
// of .docx data the user might never download.
func (r *TemplateRegistry) probe(ctx context.Context, path string) bool {
entry := r.cacheGet(path)
entry.mu.RLock()
hasData := !entry.missing && len(entry.data) > 0
hasSHA := !entry.missing && entry.sha != ""
isMissing := entry.missing
needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
entry.mu.RUnlock()
if isMissing && !needsCheck {
return false
}
if hasData || hasSHA {
return true
}
sha, err := r.giteaSHA(ctx, path)
if err != nil {
if errors.Is(err, errTemplate404) {
entry.mu.Lock()
entry.missing = true
entry.lastChecked = time.Now()
entry.mu.Unlock()
}
return false
}
entry.mu.Lock()
entry.sha = sha
entry.lastChecked = time.Now()
entry.missing = false
entry.mu.Unlock()
return true
}
// giteaSHA returns the SHA of the latest commit that touched the
// template path. Returns errTemplate404 when Gitea responds with 404 —
// the registry distinguishes "no such template" from "Gitea is down".
func (r *TemplateRegistry) giteaSHA(ctx context.Context, path string) (string, error) {
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?path=%s&limit=1&sha=%s",
templatesGiteaBaseURL,
templatesGiteaRepoOwn,
templatesGiteaRepoName,
url.QueryEscape(path),
templatesGiteaBranch,
)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", err
}
if r.giteaToken != "" {
req.Header.Set("Authorization", "token "+r.giteaToken)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", errTemplate404
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("gitea sha lookup returned %d", resp.StatusCode)
}
var commits []struct {
SHA string `json:"sha"`
}
if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil {
return "", err
}
if len(commits) == 0 {
return "", errTemplate404
}
return commits[0].SHA, nil
}
// giteaDownload fetches the raw template bytes.
func (r *TemplateRegistry) giteaDownload(ctx context.Context, path string) ([]byte, error) {
rawURL := fmt.Sprintf("%s/%s/%s/raw/branch/%s/%s",
templatesGiteaBaseURL,
templatesGiteaRepoOwn,
templatesGiteaRepoName,
templatesGiteaBranch,
path,
)
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return nil, err
}
if r.giteaToken != "" {
req.Header.Set("Authorization", "token "+r.giteaToken)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, errTemplate404
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea raw returned %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// ClearCache drops every cached entry. Exposed for an admin-side
// "refresh templates" affordance — paliad's existing /api/files/refresh
// has the same shape for the HL Patents Style proxy.
func (r *TemplateRegistry) ClearCache() {
r.cacheMu.Lock()
defer r.cacheMu.Unlock()
for k := range r.cache {
r.cache[k] = &templateCacheEntry{}
}
}