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.
This commit is contained in:
@@ -1498,6 +1498,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.import.button": "Aus Projekt importieren",
|
||||
"submissions.draft.parties.title": "Parteien",
|
||||
"submissions.draft.parties.hint": "Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.",
|
||||
// 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",
|
||||
@@ -4566,6 +4571,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-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
|
||||
@@ -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>;
|
||||
selected_parties: string[];
|
||||
last_exported_at?: string | null;
|
||||
@@ -56,6 +59,11 @@ interface SubmissionDraftView {
|
||||
has_template: boolean;
|
||||
template_missing?: boolean;
|
||||
available_parties: AvailablePartyJSON[];
|
||||
// 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 {
|
||||
@@ -411,7 +419,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; selected_parties?: string[] }): Promise<SubmissionDraftView> {
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) throw new Error("no draft id");
|
||||
if (state.inFlight) {
|
||||
@@ -463,6 +471,8 @@ function paint(): void {
|
||||
paintNameRow();
|
||||
paintImportRow();
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
}
|
||||
@@ -703,6 +713,64 @@ function formatStamp(iso: string): string {
|
||||
return d.toLocaleString(isEN() ? "en-GB" : "de-DE");
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -2620,6 +2620,10 @@ export type I18nKey =
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.import.button"
|
||||
| "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"
|
||||
|
||||
@@ -5954,6 +5954,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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
{/* t-paliad-277: "Aus Projekt importieren" + last-
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS language;
|
||||
17
internal/db/migrations/130_submission_drafts_language.up.sql
Normal file
17
internal/db/migrations/130_submission_drafts_language.up.sql
Normal 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.';
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
// AvailableParties is the project's full party roster (t-paliad-277)
|
||||
// so the frontend can render the multi-select picker in one round-
|
||||
// trip. Empty when the draft has no project attached.
|
||||
@@ -89,6 +100,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"`
|
||||
SelectedParties []uuid.UUID `json:"selected_parties"`
|
||||
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
|
||||
@@ -119,6 +131,7 @@ type submissionDraftPatchInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -353,7 +366,12 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables, SelectedParties: input.SelectedParties}
|
||||
patch := services.DraftPatch{
|
||||
Name: input.Name,
|
||||
Variables: input.Variables,
|
||||
SelectedParties: input.SelectedParties,
|
||||
Language: input.Language,
|
||||
}
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
@@ -434,7 +452,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"})
|
||||
@@ -483,7 +501,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"})
|
||||
@@ -686,6 +704,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.
|
||||
@@ -700,6 +719,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"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
}
|
||||
@@ -709,6 +729,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
g.Name = a.Name
|
||||
g.Variables = a.Variables
|
||||
g.Language = a.Language
|
||||
g.ProjectID = a.ProjectID
|
||||
g.SelectedParties = a.SelectedParties
|
||||
// Detect whether "project_id" was present in the JSON object.
|
||||
@@ -747,7 +768,12 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables, SelectedParties: in.SelectedParties}
|
||||
patch := services.DraftPatch{
|
||||
Name: in.Name,
|
||||
Variables: in.Variables,
|
||||
SelectedParties: in.SelectedParties,
|
||||
Language: in.Language,
|
||||
}
|
||||
if in.projectIDProvided {
|
||||
pid := in.ProjectID // may be nil → detach
|
||||
patch.ProjectID = &pid
|
||||
@@ -864,7 +890,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"})
|
||||
@@ -963,7 +989,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
|
||||
@@ -971,6 +997,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
|
||||
@@ -979,52 +1011,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
|
||||
@@ -1050,12 +1131,17 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
if selected == nil {
|
||||
selected = []uuid.UUID{}
|
||||
}
|
||||
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,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
|
||||
43
internal/handlers/submission_template_lang_test.go
Normal file
43
internal/handlers/submission_template_lang_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
79
internal/services/submission_draft_language_test.go
Normal file
79
internal/services/submission_draft_language_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,16 @@ import (
|
||||
// parties / deadline state to resolve). All callers must check for nil
|
||||
// before treating it as a uuid.
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
SubmissionCode string `db:"submission_code" json:"submission_code"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
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:"-"`
|
||||
SelectedPartiesRaw pq.StringArray `db:"selected_parties" json:"-"`
|
||||
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
|
||||
@@ -108,6 +113,10 @@ type DraftPatch struct {
|
||||
// the column; pass *p = nil or an empty slice to reset to "include
|
||||
// every party on the project" (the backward-compat default).
|
||||
SelectedParties *[]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
|
||||
@@ -120,7 +129,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, selected_parties,
|
||||
last_exported_at, last_exported_sha,
|
||||
last_imported_at,
|
||||
@@ -173,7 +182,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.selected_parties,
|
||||
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
|
||||
d.created_at, d.updated_at,
|
||||
@@ -280,13 +289,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)
|
||||
}
|
||||
@@ -422,6 +436,17 @@ 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
|
||||
}
|
||||
@@ -581,6 +606,10 @@ func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *Subm
|
||||
ProjectID: draft.ProjectID,
|
||||
SubmissionCode: draft.SubmissionCode,
|
||||
SelectedParties: draft.SelectedParties,
|
||||
// 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
|
||||
@@ -635,12 +664,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
|
||||
@@ -698,6 +728,19 @@ func (d *SubmissionDraft) decodeSelectedParties() 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 —
|
||||
|
||||
@@ -83,6 +83,13 @@ type SubmissionVarsContext struct {
|
||||
ProjectID *uuid.UUID
|
||||
SubmissionCode string
|
||||
SelectedParties []uuid.UUID
|
||||
// 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
|
||||
@@ -132,7 +139,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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user