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.
443 lines
14 KiB
Go
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{}
|
|
}
|
|
}
|