From 669764e86f9720962f33b2642969354cd40a918e Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 25 May 2026 16:39:29 +0200 Subject: [PATCH] mAi: #108 - t-paliad-276 submission generator language selector (DE/EN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/client/i18n.ts | 10 ++ frontend/src/client/submission-draft.ts | 69 ++++++++- frontend/src/i18n-keys.ts | 4 + frontend/src/styles/global.css | 34 +++++ frontend/src/submission-draft.tsx | 41 +++++ .../130_submission_drafts_language.down.sql | 2 + .../130_submission_drafts_language.up.sql | 17 +++ internal/handlers/files.go | 144 ++++++++++++++++++ internal/handlers/submission_drafts.go | 129 ++++++++++++---- .../handlers/submission_template_lang_test.go | 43 ++++++ internal/handlers/submissions.go | 13 +- .../submission_draft_language_test.go | 79 ++++++++++ internal/services/submission_draft_service.go | 52 ++++++- internal/services/submission_vars.go | 17 ++- 14 files changed, 614 insertions(+), 40 deletions(-) create mode 100644 internal/db/migrations/130_submission_drafts_language.down.sql create mode 100644 internal/db/migrations/130_submission_drafts_language.up.sql create mode 100644 internal/handlers/submission_template_lang_test.go create mode 100644 internal/services/submission_draft_language_test.go diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 6eff7bd..24b8cd7 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1473,6 +1473,11 @@ const translations: Record> = { "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> = { "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", diff --git a/frontend/src/client/submission-draft.ts b/frontend/src/client/submission-draft.ts index 79ceb38..e57d63b 100644 --- a/frontend/src/client/submission-draft.ts +++ b/frontend/src/client/submission-draft.ts @@ -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; 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 { return resp.json(); } -async function patchDraft(payload: { name?: string; variables?: Record; project_id?: string | null }): Promise { +async function patchDraft(payload: { name?: string; variables?: Record; project_id?: string | null; language?: string }): Promise { 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 { + 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; diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 78421b6..dfa7d7e 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -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" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 3ccbc0f..bbc5324 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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; diff --git a/frontend/src/submission-draft.tsx b/frontend/src/submission-draft.tsx index 48d15b0..a16c204 100644 --- a/frontend/src/submission-draft.tsx +++ b/frontend/src/submission-draft.tsx @@ -109,6 +109,47 @@ export function renderSubmissionDraft(): string { + {/* t-paliad-276 — output language toggle (DE/EN). + Hydrated by client/submission-draft.ts; switching + autosaves the draft and re-renders the preview. */} +
+ + Sprache + + + +
+ +

diff --git a/internal/db/migrations/130_submission_drafts_language.down.sql b/internal/db/migrations/130_submission_drafts_language.down.sql new file mode 100644 index 0000000..d233c3b --- /dev/null +++ b/internal/db/migrations/130_submission_drafts_language.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE paliad.submission_drafts + DROP COLUMN IF EXISTS language; diff --git a/internal/db/migrations/130_submission_drafts_language.up.sql b/internal/db/migrations/130_submission_drafts_language.up.sql new file mode 100644 index 0000000..bd7b376 --- /dev/null +++ b/internal/db/migrations/130_submission_drafts_language.up.sql @@ -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.'; diff --git a/internal/handlers/files.go b/internal/handlers/files.go index 4682091..58d0475 100644 --- a/internal/handlers/files.go +++ b/internal/handlers/files.go @@ -79,6 +79,20 @@ var fileRegistry = map[string]fileEntry{ RepoName: "mWorkRepo", FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_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 @@ -87,6 +101,11 @@ var fileRegistry = map[string]fileEntry{ // the same string the registry uses. const skeletonSubmissionSlug = "submission/_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 @@ -96,14 +115,32 @@ const skeletonSubmissionSlug = "submission/_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 { @@ -209,6 +246,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 diff --git a/internal/handlers/submission_drafts.go b/internal/handlers/submission_drafts.go index 66c8f20..50caa2d 100644 --- a/internal/handlers/submission_drafts.go +++ b/internal/handlers/submission_drafts.go @@ -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 = `

Vorlage konnte nicht geladen werden.

` 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,41 +924,83 @@ 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: +// (submission_code, language). Lookup order, t-paliad-276: // -// 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. universal _skeleton.docx — same variable bag, no submission_code- -// specific prose. Catches every code without a dedicated template -// so the editor preview / generate flow still has variables to -// substitute instead of falling through to the bare letterhead. -// 3. universal HL Patents Style .dotm — macro-only letterhead, no -// placeholders. Final fallback when even the skeleton is unreachable -// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour -// for resilience. +// 1. per-firm per-(code,lang) template — e.g. de.inf.lg.erwidg.en.docx +// 2. per-firm per-code (unsuffixed) template — DE-baked baseline +// 3. universal _skeleton.{lang}.docx — language-matched skeleton +// 4. universal _skeleton.docx — DE-baked skeleton (fallback for EN +// drafts when no EN skeleton is authored yet) +// 5. universal HL Patents Style .dotm — macro-only letterhead, last +// resort when even the skeleton 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 requested +// language so it can surface a fallback 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 } - if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil { - return data, sha, 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 + 4. skeleton (lang-matched, else DE) + if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil { + tier := tplTierSkeleton + if langMatched { + tier = tplTierSkeletonLang + } + return data, sha, tier, 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) } + // 5. 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 @@ -958,12 +1022,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, diff --git a/internal/handlers/submission_template_lang_test.go b/internal/handlers/submission_template_lang_test.go new file mode 100644 index 0000000..0817dbf --- /dev/null +++ b/internal/handlers/submission_template_lang_test.go @@ -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) + } + }) + } +} diff --git a/internal/handlers/submissions.go b/internal/handlers/submissions.go index 9dc1256..a4c0278 100644 --- a/internal/handlers/submissions.go +++ b/internal/handlers/submissions.go @@ -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{ diff --git a/internal/services/submission_draft_language_test.go b/internal/services/submission_draft_language_test.go new file mode 100644 index 0000000..37e8a46 --- /dev/null +++ b/internal/services/submission_draft_language_test.go @@ -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) + } + } +} diff --git a/internal/services/submission_draft_service.go b/internal/services/submission_draft_service.go index 9803672..c5db250 100644 --- a/internal/services/submission_draft_service.go +++ b/internal/services/submission_draft_service.go @@ -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 — diff --git a/internal/services/submission_vars.go b/internal/services/submission_vars.go index b7a212a..ed81cb5 100644 --- a/internal/services/submission_vars.go +++ b/internal/services/submission_vars.go @@ -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" }