Compare commits

...

1 Commits

Author SHA1 Message Date
mAi
af7a1e9c56 mAi: #108 - t-paliad-276 submission generator language selector (DE/EN)
Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).

Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
  CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
  preserving every legacy draft's behaviour byte-for-byte.

Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
  uses it when set; falls back to user.Lang otherwise — Slice 1's
  format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
  through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
  outside {de,en}. Project-scoped + global PATCH endpoints both
  surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
  predecessor. Returns the matched tier (per_code_lang / per_code /
  skeleton_lang / skeleton / letterhead) so the editor knows whether
  to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
  alongside the DE one; per-code EN variants land in a parallel
  submissionTemplateENRegistry (empty for now — EN templates land per
  HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
  `?language=de|en` query override (one-shot path, no draft row to
  pull the column from); defaults to the user's UI lang.

Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
  Switching the radio PATCHes `language` and the server returns the
  freshly-resolved bag + preview HTML so the lawyer sees EN values
  immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
  sprachspezifische Vorlage)") shows when the resolved tier doesn't
  match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.

Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
  rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.

Build hygiene: go build/vet/test clean; bun run build clean.
2026-05-25 16:45:04 +02:00
14 changed files with 628 additions and 47 deletions

View File

@@ -1473,6 +1473,11 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
"submissions.draft.preview.title": "Vorschau",
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Sprache",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
@@ -4520,6 +4525,11 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.switcher.label": "Draft",
"submissions.draft.name.placeholder": "Name of this draft",
"submissions.draft.preview.title": "Preview",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Language",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",

View File

@@ -20,6 +20,9 @@ interface SubmissionDraftJSON {
submission_code: string;
user_id: string;
name: string;
// t-paliad-276 — per-draft output language ("de" or "en"). Drives the
// template-variant lookup and language-aware variable resolution.
language: string;
variables: Record<string, string>;
last_exported_at?: string | null;
last_exported_sha?: string | null;
@@ -46,6 +49,11 @@ interface SubmissionDraftView {
lang: string;
has_template: boolean;
template_missing?: boolean;
// t-paliad-276 — template-tier metadata used to surface the
// "Fallback: universelles Skelett" notice when the requested draft
// language has no per-firm language-matched template.
template_tier?: string;
language_fallback?: boolean;
}
interface SubmissionDraftListResponse {
@@ -401,7 +409,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; language?: string }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
@@ -451,6 +459,8 @@ function paint(): void {
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
}
@@ -562,6 +572,63 @@ function paintNameRow(): void {
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
}
// paintLanguageRow syncs the DE/EN radio with the loaded draft's
// language. Switching the radio fires onLanguageChange which PATCHes
// the draft and lets the server return the freshly-resolved bag +
// preview HTML (so the lawyer sees the EN form names appear without a
// manual reload). t-paliad-276.
function paintLanguageRow(): void {
if (!state.view) return;
const lang = (state.view.draft.language || "de").toLowerCase();
const de = document.getElementById("submission-draft-language-de") as HTMLInputElement | null;
const en = document.getElementById("submission-draft-language-en") as HTMLInputElement | null;
if (de) {
de.checked = lang === "de";
de.onchange = () => { void onLanguageChange("de"); };
}
if (en) {
en.checked = lang === "en";
en.onchange = () => { void onLanguageChange("en"); };
}
}
// paintLanguageFallback shows / hides the "no language-matched
// template" notice. The server sets language_fallback=true when the
// resolved template tier doesn't match the draft's language
// (e.g. EN draft → DE per-code template, or no skeleton EN sibling).
function paintLanguageFallback(): void {
const el = document.getElementById("submission-draft-language-fallback");
if (!el) return;
const fallback = !!state.view?.language_fallback;
el.style.display = fallback ? "" : "none";
}
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
if (!state.view) return;
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ language: lang });
state.view = view;
// Repaint everything that depends on language: the DE/EN form
// values in the resolved bag, the localized rule name in the
// header, and the fallback notice.
paintHeader();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft language switch:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
// Revert the radio to the persisted value so the UI doesn't lie
// about which language is active.
paintLanguageRow();
}
}
function paintVariables(): void {
const host = document.getElementById("submission-draft-variables");
if (!host || !state.view) return;

View File

@@ -2599,6 +2599,10 @@ export type I18nKey =
| "submissions.draft.action.export"
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.language"
| "submissions.draft.language.de"
| "submissions.draft.language.en"
| "submissions.draft.language.fallback_notice"
| "submissions.draft.loading"
| "submissions.draft.name.placeholder"
| "submissions.draft.notfound"

View File

@@ -5774,6 +5774,40 @@ dialog.modal::backdrop {
color: var(--color-danger, #c00);
}
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
as the rest of the sidebar mini-controls; muted label + inline radios
so it doesn't compete with the editor's primary inputs. */
.submission-draft-language-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.25rem 0 0.5rem 0;
font-size: 0.9em;
}
.submission-draft-language-label {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.85em;
}
.submission-draft-language-option {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
.submission-draft-language-fallback {
font-size: 0.85em;
color: var(--color-text-muted);
margin: 0 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border-left: 2px solid var(--color-warning, #d4a017);
background: var(--color-warning-bg, rgba(212, 160, 23, 0.08));
}
.submission-draft-variables {
display: flex;
flex-direction: column;

View File

@@ -109,6 +109,47 @@ export function renderSubmissionDraft(): string {
</button>
</div>
{/* t-paliad-276 — output language toggle (DE/EN).
Hydrated by client/submission-draft.ts; switching
autosaves the draft and re-renders the preview. */}
<div
className="submission-draft-language-row"
id="submission-draft-language-row"
role="radiogroup"
aria-labelledby="submission-draft-language-label">
<span
id="submission-draft-language-label"
className="submission-draft-language-label"
data-i18n="submissions.draft.language">
Sprache
</span>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="de"
id="submission-draft-language-de"
/>
<span data-i18n="submissions.draft.language.de">DE</span>
</label>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="en"
id="submission-draft-language-en"
/>
<span data-i18n="submissions.draft.language.en">EN</span>
</label>
</div>
<p
className="submission-draft-language-fallback"
id="submission-draft-language-fallback"
style="display:none"
data-i18n="submissions.draft.language.fallback_notice">
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
</p>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
<div className="submission-draft-variables" id="submission-draft-variables" />

View File

@@ -0,0 +1,2 @@
ALTER TABLE paliad.submission_drafts
DROP COLUMN IF EXISTS language;

View File

@@ -0,0 +1,17 @@
-- t-paliad-276 / m/paliad#108: per-draft output language for the
-- Submissions generator.
--
-- The submission editor lets the lawyer pick DE or EN per draft so the
-- generator selects the matching template variant + resolves language-
-- aware variables ({{procedural_event.name_de}} vs _en). Default is
-- 'de' to match the primary-language convention in CLAUDE.md and to
-- keep existing rows behaving exactly as before (every legacy draft
-- was implicitly DE; the resolved bag for those drafts is unchanged
-- under language='de').
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS language text NOT NULL DEFAULT 'de'
CONSTRAINT submission_drafts_language_check CHECK (language IN ('de', 'en'));
COMMENT ON COLUMN paliad.submission_drafts.language IS
't-paliad-276: output language for the generated .docx. ''de'' or ''en''. Drives template variant selection ({code}.{lang}.docx fallback chain) and language-aware variable resolution.';

View File

@@ -97,6 +97,20 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
},
// English skeleton variant (t-paliad-276). Sibling of
// `_skeleton.docx`; used when a draft's language='en' and no
// per-code EN template exists. If the file isn't authored yet in
// mWorkRepo, the Gitea fetch fails and resolveSubmissionTemplate
// falls through to the DE skeleton — visible to the user as the
// "Fallback: universelles Skelett" notice on the draft editor.
skeletonSubmissionENSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
DownloadName: branding.Name + " — Submission skeleton.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
@@ -113,6 +127,11 @@ const skeletonSubmissionSlug = "submission/_skeleton.docx"
// codes without a dedicated template still render with firm branding.
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
// skeletonSubmissionENSlug names the English skeleton variant used when
// a draft's language='en' and no per-code EN template exists
// (t-paliad-276). Same role as skeletonSubmissionSlug but in EN.
const skeletonSubmissionENSlug = "submission/_skeleton.en.docx"
// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
@@ -122,14 +141,32 @@ const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
// the file itself lives in mWorkRepo and is served through the shared
// Gitea proxy cache so refreshes are visible to all consumers in one
// place.
//
// t-paliad-276: codes that ship an EN sibling
// (e.g. `de.inf.lg.erwidg.en.docx`) also register it in
// submissionTemplateENRegistry; the language-aware lookup
// (resolveSubmissionTemplate(ctx, code, lang)) prefers the language-
// suffixed slug and falls back to the unsuffixed one when no per-firm
// EN variant exists.
var submissionTemplateRegistry = map[string]string{
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
}
// submissionTemplateENRegistry maps a submission_code to the EN
// variant slug. Empty when no EN template has been authored — the
// lookup falls through to the unsuffixed (DE-baked) template and the
// editor surfaces the "Fallback: universelles Skelett" notice when
// even the skeleton has no EN sibling.
var submissionTemplateENRegistry = map[string]string{}
// fetchSubmissionTemplateBytes returns the per-submission_code template
// bytes (and provenance SHA) when one is registered. The bool result
// distinguishes "no per-code template registered" (callers fall back to
// HL Patents Style) from an upstream fetch error.
//
// Language-suffixed variants (t-paliad-276) are served via
// fetchSubmissionTemplateBytesForLang — this base function returns the
// unsuffixed registry entry only (the legacy DE-baked template).
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
slug, ok := submissionTemplateRegistry[submissionCode]
if !ok {
@@ -235,6 +272,113 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
// fetchSubmissionTemplateBytesForLang returns the per-(code, lang)
// template bytes when a language-suffixed variant is registered. Used
// only for the EN variant today; DE goes through the unsuffixed
// fetchSubmissionTemplateBytes (which is the legacy / authoritative
// DE registry). t-paliad-276.
//
// Returned bool = "variant registered AND fetched OK". A registered
// variant whose file 404s on Gitea returns (nil, "", false, nil) so
// the caller falls through to the unsuffixed template, mirroring the
// behaviour for unregistered codes.
func fetchSubmissionTemplateBytesForLang(ctx context.Context, submissionCode, lang string) ([]byte, string, bool, error) {
if lang != "en" {
// Only EN has a separate registry today. DE goes through the
// unsuffixed path which is the authoritative DE template.
return nil, "", false, nil
}
slug, ok := submissionTemplateENRegistry[submissionCode]
if !ok {
return nil, "", false, nil
}
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
}
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
// Treat upstream miss as "variant unavailable" so the
// resolver falls through to the DE template instead of
// surfacing a 502.
log.Printf("file proxy: EN variant fetch failed for %s (%s): %v — falling through", submissionCode, slug, err)
return nil, "", false, nil
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", false, nil
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, true, nil
}
// fetchSubmissionSkeletonBytesForLang returns the cached skeleton
// template bytes for the requested language. EN falls back to DE when
// the EN skeleton hasn't been authored yet (t-paliad-276). Returned
// bool flags whether the bytes match the requested language — false
// means the resolver should communicate "fallback" to the UI.
func fetchSubmissionSkeletonBytesForLang(ctx context.Context, lang string) ([]byte, string, bool, error) {
if lang == "en" {
entry, ok := fileRegistry[skeletonSubmissionENSlug]
if ok {
ce := getCacheEntry(skeletonSubmissionENSlug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err == nil {
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
} else {
log.Printf("file proxy: EN skeleton fetch failed (%s): %v — falling back to DE", skeletonSubmissionENSlug, err)
}
} else {
if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
}
}
}
// Fall through to the DE skeleton; bool=false flags that the
// returned bytes don't carry the requested language.
bytes, sha, err := fetchSubmissionSkeletonBytes(ctx)
if err != nil {
return nil, "", false, err
}
return bytes, sha, lang == "de", nil
}
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
// template bytes plus its provenance SHA. Sits between the per-firm
// per-submission_code template (fetchSubmissionTemplateBytes) and the

View File

@@ -68,6 +68,17 @@ type submissionDraftView struct {
Lang string `json:"lang"`
HasTemplate bool `json:"has_template"`
TemplateMissing bool `json:"template_missing,omitempty"`
// TemplateTier identifies which tier of resolveSubmissionTemplate
// produced the bytes — one of per_code_lang, per_code, skeleton_lang,
// skeleton, letterhead. Lets the editor distinguish a perfect
// per-firm match from a skeleton fallback. t-paliad-276.
TemplateTier string `json:"template_tier,omitempty"`
// LanguageFallback is true when the requested draft.language has no
// per-firm per-code template (e.g. EN draft falls back to the DE
// per-code template, or to the universal skeleton). UI surfaces a
// notice so the lawyer knows the rendered body lacks language-
// matched code-specific prose. t-paliad-276.
LanguageFallback bool `json:"language_fallback,omitempty"`
}
type submissionDraftJSON struct {
@@ -76,6 +87,7 @@ type submissionDraftJSON struct {
SubmissionCode string `json:"submission_code"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Language string `json:"language"`
Variables services.PlaceholderMap `json:"variables"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
@@ -103,6 +115,7 @@ type submissionDraftListResponse struct {
type submissionDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
}
// ─────────────────────────────────────────────────────────────────────
@@ -337,7 +350,7 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables, Language: input.Language}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -418,7 +431,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -467,7 +480,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -670,6 +683,7 @@ func handleGetGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
type globalDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
// projectIDProvided is true when the JSON included the "project_id"
// key (regardless of value); needed to distinguish "no change" from
// "set to null". Set by the custom UnmarshalJSON below.
@@ -681,6 +695,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
type alias struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
}
var a alias
@@ -689,6 +704,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
}
g.Name = a.Name
g.Variables = a.Variables
g.Language = a.Language
g.ProjectID = a.ProjectID
// Detect whether "project_id" was present in the JSON object.
var raw map[string]json.RawMessage
@@ -726,7 +742,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables, Language: in.Language}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
@@ -801,7 +817,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -886,7 +902,7 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
}
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
view.TemplateMissing = true
@@ -894,6 +910,12 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.PreviewHTML = `<p class="preview-error">Vorlage konnte nicht geladen werden.</p>`
return view, nil
}
view.TemplateTier = string(tier)
// LanguageFallback signals "no per-firm template in the requested
// language" — the editor surfaces a notice so the lawyer knows the
// rendered body lacks code-specific prose. The per-code DE template
// counts as a fallback when the requested language is EN.
view.LanguageFallback = languageFallback(d.Language, tier)
html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
if err != nil {
return nil, err
@@ -902,52 +924,101 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
return view, nil
}
// submissionTemplateTier enumerates which tier of the template
// fallback chain produced the bytes returned by resolveSubmissionTemplate.
// Used by the editor to surface "Fallback: universelles Skelett" when
// the requested (code, lang) didn't have a dedicated template.
type submissionTemplateTier string
const (
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
)
// resolveSubmissionTemplate returns the .docx bytes for the given
// submission code. Lookup order matches the cronus design fallback chain
// §8 plus the t-paliad-259 universal-skeleton slot and the t-paliad-275
// firm-skeleton slot:
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
//
// 1. per-firm per-submission_code template registered in
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
// specific structure plus the full variable bag.
// 2. firm-formatted _firm-skeleton.docx — full HL paragraph + character
// styles (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
// HLpat-Table-Recitals-*, HLpat-Signature, …) preserved from the
// source .dotm, the firm letterhead header/footer, plus the full
// 48-key placeholder bag. Catches every code without a dedicated
// template so the editor still renders firm-branded output.
// 3. universal _skeleton.docx — same variable bag, no firm formatting.
// Backstop for when the firm skeleton is unreachable (e.g. a future
// firm hasn't authored one yet).
// 4. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Final fallback when even both skeletons are
// unreachable (mWorkRepo outage etc.). Preserves the
// pre-t-paliad-259 behaviour for resilience.
// 1. per-firm per-(code, lang) template — most specific. e.g.
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
// 2. per-firm per-code (unsuffixed) template — DE-baked baseline. The
// legacy registry shape from before the language selector landed.
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
// HL paragraph + character styles + letterhead, full placeholder
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
// Backstop when the firm skeleton is unreachable.
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Last-ditch when every skeleton tier is unreachable.
//
// The returned SHA is the cache entry's commit SHA so the export audit
// row can record provenance.
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", err
// The returned SHA pins the audit row's template provenance. The tier
// tells the editor whether the result language-matches the request so
// it can surface a "Fallback: universelles Skelett" notice.
func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string) ([]byte, string, submissionTemplateTier, error) {
if lang != "de" && lang != "en" {
lang = "de"
}
// 1. per-(code, lang)
if data, sha, found, err := fetchSubmissionTemplateBytesForLang(ctx, submissionCode, lang); err != nil {
return nil, "", "", err
} else if found {
return data, sha, nil
return data, sha, tplTierPerCodeLang, nil
}
// 2. per-code (unsuffixed)
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", "", err
} else if found {
return data, sha, tplTierPerCode, nil
}
// 3. language-matched skeleton — only meaningful for EN drafts; DE
// drafts fall through to the firm/universal DE skeletons below.
if lang == "en" {
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
return data, sha, tplTierSkeletonLang, nil
}
}
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
// this is a first-class match; for EN drafts it counts as a
// language fallback (handled by languageFallback()).
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
return data, sha, nil
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s, falling back to universal skeleton: %v", submissionCode, err)
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
}
// 5. universal plain DE skeleton.
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, nil
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
}
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
bytes, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
return nil, "", err
return nil, "", "", err
}
sha := hlPatentsStyleSHA()
return bytes, sha, nil
return bytes, sha, tplTierLetterhead, nil
}
// languageFallback reports whether the resolved template tier failed
// to match the requested draft language. For an EN draft, anything
// other than per_code_lang or skeleton_lang is a fallback (per_code is
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
// draft, only `letterhead` counts as a fallback — the DE skeleton and
// per-code template are both first-class DE outputs. t-paliad-276.
func languageFallback(lang string, tier submissionTemplateTier) bool {
if tier == tplTierLetterhead {
return true
}
if strings.EqualFold(lang, "en") {
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
}
return false
}
// hlPatentsStyleSHA reads the current cache SHA for the universal
@@ -969,12 +1040,17 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
if vars == nil {
vars = services.PlaceholderMap{}
}
lang := d.Language
if lang == "" {
lang = "de"
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,

View File

@@ -0,0 +1,43 @@
package handlers
// Regression tests for the template-tier → language-fallback mapping
// (t-paliad-276). The editor surfaces a "Fallback: universelles
// Skelett" notice when the requested draft language has no per-firm
// language-matched template — these tests pin which tier counts as a
// fallback for each language so the UI signal stays stable.
import "testing"
func TestLanguageFallback(t *testing.T) {
t.Parallel()
cases := []struct {
name string
lang string
tier submissionTemplateTier
want bool
}{
// DE drafts: every non-letterhead tier is a first-class match.
{"de_per_code_lang", "de", tplTierPerCodeLang, false},
{"de_per_code", "de", tplTierPerCode, false},
{"de_skeleton_lang", "de", tplTierSkeletonLang, false},
{"de_skeleton", "de", tplTierSkeleton, false},
{"de_letterhead", "de", tplTierLetterhead, true},
// EN drafts: per_code (DE-baked) and skeleton (DE-baked) both
// surface the fallback notice so the lawyer knows the rendered
// body lacks EN prose.
{"en_per_code_lang", "en", tplTierPerCodeLang, false},
{"en_per_code", "en", tplTierPerCode, true},
{"en_skeleton_lang", "en", tplTierSkeletonLang, false},
{"en_skeleton", "en", tplTierSkeleton, true},
{"en_letterhead", "en", tplTierLetterhead, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := languageFallback(c.lang, c.tier); got != c.want {
t.Errorf("languageFallback(%q, %q) = %v, want %v", c.lang, c.tier, got, c.want)
}
})
}
}

View File

@@ -304,14 +304,23 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel()
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
// One-shot /generate has no draft row to pull `language` from —
// accept `?language=de|en` as an explicit override (t-paliad-276)
// and otherwise fall back to the user's UI language.
user, _ := dbSvc.users.GetByID(ctx, uid)
lang := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("language")))
if lang != "de" && lang != "en" {
lang = userLang(user)
}
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, submissionCode, lang)
if err != nil {
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, lang, tplBytes)
if err != nil {
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{

View File

@@ -0,0 +1,79 @@
package services
// Regression tests for the per-draft language column (t-paliad-276).
// The draft's `language` value drives both the placeholder-bag
// language pick (`procedural_event.name` → name_de vs name_en) and the
// template-variant lookup (`{code}.{lang}.docx` fallback chain). These
// tests pin the pure-function pieces — Build wiring needs DB fixtures
// and lives in the handler-layer smoke path.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestNormalizeDraftLanguage(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"de", "de"},
{"DE", "de"},
{" de ", "de"},
{"en", "en"},
{"EN", "en"},
{" en ", "en"},
{"fr", "de"}, // unknown collapses to de (the CHECK-allowed default)
{"", "de"},
{"english", "de"}, // strict — only the canonical two-letter code is accepted
}
for _, c := range cases {
if got := normalizeDraftLanguage(c.in); got != c.want {
t.Errorf("normalizeDraftLanguage(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// The placeholder bag picks the language-matched value for the
// canonical (procedural_event.name) and legacy (rule.name) keys based
// on the lang argument. This pins the wiring used by Build when a
// draft's language overrides the user's UI lang (t-paliad-276).
func TestAddRuleVars_LanguageSelectsMatchedName(t *testing.T) {
t.Parallel()
code := "de.inf.lg.erwidg"
rule := &models.DeadlineRule{
ID: uuid.New(),
SubmissionCode: &code,
Name: "Klageerwiderung",
NameEN: "Statement of Defence",
}
for _, lang := range []string{"de", "en"} {
bag := PlaceholderMap{}
addRuleVars(bag, rule, lang)
want := rule.Name
if strings.EqualFold(lang, "en") {
want = rule.NameEN
}
if got := bag["procedural_event.name"]; got != want {
t.Errorf("lang=%s: procedural_event.name = %q, want %q", lang, got, want)
}
if got := bag["rule.name"]; got != want {
t.Errorf("lang=%s: rule.name = %q, want %q (legacy alias must mirror canonical)", lang, got, want)
}
// The explicit *_de / *_en keys never change — both are always
// emitted so a template can pin one regardless of the draft's
// language. Regression guard against accidentally
// language-gating the explicit variants.
if bag["procedural_event.name_de"] != rule.Name {
t.Errorf("lang=%s: procedural_event.name_de = %q, want %q", lang, bag["procedural_event.name_de"], rule.Name)
}
if bag["procedural_event.name_en"] != rule.NameEN {
t.Errorf("lang=%s: procedural_event.name_en = %q, want %q", lang, bag["procedural_event.name_en"], rule.NameEN)
}
}
}

View File

@@ -47,6 +47,11 @@ type SubmissionDraft struct {
SubmissionCode string `db:"submission_code" json:"submission_code"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
// Language is the output language for the generated .docx — 'de' or
// 'en'. Drives the template-variant lookup ({code}.{lang}.docx
// fallback chain) and language-aware variable resolution
// ({{procedural_event.name}} → name_de or name_en). t-paliad-276.
Language string `db:"language" json:"language"`
VariablesRaw []byte `db:"variables" json:"-"`
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
@@ -94,6 +99,9 @@ type DraftPatch struct {
Name *string
Variables *PlaceholderMap
ProjectID **uuid.UUID
// Language sets the output language. Valid values: "de", "en".
// Anything else returns ErrInvalidInput. t-paliad-276.
Language *string
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -106,7 +114,7 @@ var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already tak
// draftColumns is the canonical select list — kept in one place so
// every fetch stays in sync.
const draftColumns = `id, project_id, submission_code, user_id, name,
const draftColumns = `id, project_id, submission_code, user_id, name, language,
variables, last_exported_at, last_exported_sha,
created_at, updated_at`
@@ -157,7 +165,7 @@ type DraftWithProject struct {
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
var rows []DraftWithProject
err := s.db.SelectContext(ctx, &rows,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
d.variables, d.last_exported_at, d.last_exported_sha,
d.created_at, d.updated_at,
p.title AS project_title,
@@ -263,13 +271,18 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
if err != nil {
return nil, err
}
// Seed the new draft's output language from the user's UI lang so
// the editor opens in the language the lawyer is already working in.
// Anything other than "en" normalizes to "de" — matches the DB CHECK
// constraint and the project's primary-language default.
draftLang := normalizeDraftLanguage(lang)
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
`INSERT INTO paliad.submission_drafts
(project_id, submission_code, user_id, name)
VALUES ($1, $2, $3, $4)
(project_id, submission_code, user_id, name, language)
VALUES ($1, $2, $3, $4, $5)
RETURNING `+draftColumns,
projectID, submissionCode, userID, name)
projectID, submissionCode, userID, name, draftLang)
if err != nil {
return nil, fmt.Errorf("create submission draft: %w", err)
}
@@ -394,6 +407,16 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.Language != nil {
newLang := strings.ToLower(strings.TrimSpace(*patch.Language))
if newLang != "de" && newLang != "en" {
return nil, ErrInvalidInput
}
setParts = append(setParts, fmt.Sprintf("language = $%d", idx))
args = append(args, newLang)
idx++
}
if len(setParts) == 0 {
return existing, nil
}
@@ -476,6 +499,10 @@ func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *Subm
UserID: draft.UserID,
ProjectID: draft.ProjectID,
SubmissionCode: draft.SubmissionCode,
// The draft's language overrides the user's UI lang — the lawyer
// can author an EN draft in a DE-UI session and vice versa
// (t-paliad-276). Empty / unknown falls back to "de".
Lang: normalizeDraftLanguage(draft.Language),
})
if err != nil {
return nil, nil, err
@@ -530,12 +557,13 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
// ProjectService.GetByID — callers get ErrNotFound on no-access.
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
// requested submission_code.
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
pid := projectID
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: userID,
ProjectID: &pid,
SubmissionCode: submissionCode,
Lang: normalizeDraftLanguage(lang),
})
if err != nil {
return nil, nil, err
@@ -562,6 +590,18 @@ func (d *SubmissionDraft) decodeVariables() error {
return nil
}
// normalizeDraftLanguage maps any input to one of the two allowed
// language values for paliad.submission_drafts.language. Anything other
// than "en" (case-insensitive) collapses to "de" — matches the DB CHECK
// constraint, the project's primary-language default, and the seed
// behaviour for existing rows that came in before the column existed.
func normalizeDraftLanguage(lang string) string {
if strings.EqualFold(strings.TrimSpace(lang), "en") {
return "en"
}
return "de"
}
// Compile-time guard: ensure the *models.User reference in the import
// graph doesn't get optimised away by linters. The service doesn't
// dereference User directly — that happens in SubmissionVarsService —

View File

@@ -76,6 +76,13 @@ type SubmissionVarsContext struct {
UserID uuid.UUID
ProjectID *uuid.UUID
SubmissionCode string
// Lang pins the output language for this Build, overriding the
// caller's UI preference (user.Lang). When empty, Build falls back
// to user.Lang so existing callers (the format-only Slice 1 path)
// keep working unchanged. The draft editor passes the per-draft
// `language` column (t-paliad-276) so DE/EN can be picked
// independently of the UI session.
Lang string
}
// SubmissionVarsResult bundles the placeholder map with the lookup
@@ -125,7 +132,15 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return nil, err
}
lang := user.Lang
// Per-call Lang override (t-paliad-276) wins over the user's UI
// language so the draft editor can render an EN .docx from a DE-UI
// session and vice versa. Falls back to the user pref when the
// caller didn't specify, preserving the format-only Slice 1
// behaviour.
lang := strings.ToLower(strings.TrimSpace(in.Lang))
if lang != "de" && lang != "en" {
lang = user.Lang
}
if lang == "" {
lang = "de"
}