Surface Slovenian A1 content in prompt page; purge English defaults

Three bugs hid the split A1 data behind English junk:

1. /api/auth/me company was only {id, name} — me.company.language never
   existed, so the prompt/formula pages ALWAYS fell back to English as
   the primary content language and never showed the Slovenian ("sl")
   prompt sections by default. handleMe now enriches the active company
   with language + content_languages from the companies table.

2. The unified prompt page injected English default texts: meta title /
   meta description fields were pre-filled via getDefaultMetaTitle/
   Description (and silently SAVED on Save), new description sections
   started with English instructions, and type changes overwrote user
   text with English defaults. All removed — empty stays empty
   (placeholders only), stored tenant content is shown exactly as-is,
   meta-only templates now save (sections no longer required).

3. Sync A1 / repair left English formula artifacts on categories with no
   seed content ("Audio video" etc.): earlier repairs backfilled
   DefaultRetailTitleFormula and cats.json-style English description
   sections onto every category. Repair now (a) never invents a default
   title formula — formulas derive ONLY from legacy naming rules — and
   (b) with the seed as source of truth clears title_template and
   description_template on non-seed categories (new
   title/description_template_cleared counters).

Locally verified end-to-end: corrupted Platform Demo Monitorji (legacy
combined blob) + Audio video (English junk), called the real admin
POST /companies/{id}/sync-a1 as the platform admin — Monitorji restored
to the exact split Slovenian Title/Description/Meta + formulas +
Slovenian meta title/description, Audio video fully cleared; repair
idempotent (0 on re-run); me.company.language returns sl for A1;
zero English-default templates remain in A1 + Platform Demo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 01:49:44 +02:00
co-authored by Claude Fable 5
parent c9554f2f7c
commit 0ff24b1534
3 changed files with 94 additions and 51 deletions
+43 -1
View File
@@ -37,6 +37,10 @@ type RepairCategoryEnhancePromptsResult struct {
FallbackTemplate int `json:"fallback_template"` FallbackTemplate int `json:"fallback_template"`
TitleTemplateBackfill int `json:"title_template_backfill"` TitleTemplateBackfill int `json:"title_template_backfill"`
DescTemplateBackfill int `json:"description_template_backfill"` DescTemplateBackfill int `json:"description_template_backfill"`
// *_Cleared count non-seed categories whose leftover formula artifacts
// (English defaults from earlier repairs / cats.json / migration) were removed.
TitleTemplateCleared int `json:"title_template_cleared"`
DescTemplateCleared int `json:"description_template_cleared"`
ByCompany map[string]int `json:"by_company"` ByCompany map[string]int `json:"by_company"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
SeedPath string `json:"seed_path,omitempty"` SeedPath string `json:"seed_path,omitempty"`
@@ -524,7 +528,19 @@ func repairCompanyCategoryEnhancePrompts(
if needDesc && strings.TrimSpace(legacyParts.DescriptionRules) == "" && strings.TrimSpace(legacyParts.MetaRules) == "" { if needDesc && strings.TrimSpace(legacyParts.DescriptionRules) == "" && strings.TrimSpace(legacyParts.MetaRules) == "" {
needDesc = false needDesc = false
} }
if !needPrompt && !needTitle && !needDesc { // Never invent a default title formula: formulas are derived ONLY from
// legacy naming rules. (Earlier repairs backfilled DefaultRetailTitleFormula
// with English labels onto every category — that junk gets cleared below.)
if strings.TrimSpace(titleRules) == "" {
needTitle = false
}
// Seed is the source of truth: categories with no seed/legacy content must
// not keep formula artifacts (English default sections/formulas from
// earlier repairs, cats.json seeding, or migration).
noLegacyContent := seedLegacy == "" && !legacyParts.WasLegacy && strings.TrimSpace(titleRules) == ""
clearTitleTpl := forceFromSeed && noLegacyContent && jsonbTemplatePresent(titleTpl)
clearDescTpl := forceFromSeed && noLegacyContent && jsonbTemplatePresent(descTpl)
if !needPrompt && !needTitle && !needDesc && !clearTitleTpl && !clearDescTpl {
if company.HasAnyPrompt(m) { if company.HasAnyPrompt(m) {
out.AlreadyOK++ out.AlreadyOK++
} else { } else {
@@ -564,6 +580,12 @@ func repairCompanyCategoryEnhancePrompts(
if needDesc { if needDesc {
out.DescTemplateBackfill++ out.DescTemplateBackfill++
} }
if clearTitleTpl {
out.TitleTemplateCleared++
}
if clearDescTpl {
out.DescTemplateCleared++
}
updatedHere++ updatedHere++
continue continue
} }
@@ -640,6 +662,26 @@ func repairCompanyCategoryEnhancePrompts(
} }
} }
if clearTitleTpl {
if _, err := pool.Exec(ctx, `
UPDATE categories
SET title_template = NULL, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID); err != nil {
return updatedHere, fmt.Errorf("clear title_template category=%s: %w", id, err)
}
out.TitleTemplateCleared++
}
if clearDescTpl {
if _, err := pool.Exec(ctx, `
UPDATE categories
SET description_template = NULL, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID); err != nil {
return updatedHere, fmt.Errorf("clear description_template category=%s: %w", id, err)
}
out.DescTemplateCleared++
}
out.Updated++ out.Updated++
updatedHere++ updatedHere++
} }
@@ -340,6 +340,24 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
out["company"] = c out["company"] = c
} }
} }
// Enrich with content language settings: the dashboard (category prompt /
// formula pages, language switchers) reads me.company.language as the
// primary content language — auth.Company alone is only {id, name},
// which silently fell back to English for every tenant.
if c, ok := out["company"].(auth.Company); ok {
var lang string
var contentLangs []string
if err := s.Pool.QueryRow(r.Context(), `
SELECT COALESCE(language, 'en'), COALESCE(content_languages, '{}')
FROM companies WHERE id = $1`, cid).Scan(&lang, &contentLangs); err == nil {
out["company"] = map[string]any{
"id": c.ID,
"name": c.Name,
"language": lang,
"content_languages": contentLangs,
}
}
}
if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil { if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil {
out["credits"] = credits out["credits"] = credits
} }
@@ -10,9 +10,6 @@
buildTemplateToSave, buildTemplateToSave,
elementId, elementId,
generatePreviewElements, generatePreviewElements,
getDefaultInstructions,
getDefaultMetaDescription,
getDefaultMetaTitle,
parseDescriptionTemplate, parseDescriptionTemplate,
parseTemplateToFormula parseTemplateToFormula
} from "$lib/categories/formula"; } from "$lib/categories/formula";
@@ -137,10 +134,13 @@
let confirmOpen = $state(false); let confirmOpen = $state(false);
let itemToDelete = $state<{ type: string; id: string } | null>(null); let itemToDelete = $state<{ type: string; id: string } | null>(null);
// Description formula (categories.description_template) — same editor as /description-formula. // Description formula (categories.description_template) — same editor as
// /description-formula, but NEVER pre-filled with English default texts:
// empty stays empty (placeholders only) so stored Slovenian/tenant content
// is shown exactly as-is and defaults are never silently saved.
let descSections = $state<DescriptionSection[]>([]); let descSections = $state<DescriptionSection[]>([]);
let metaTitle = $state(getDefaultMetaTitle()); let metaTitle = $state("");
let metaDescription = $state(getDefaultMetaDescription()); let metaDescription = $state("");
let dragIndex = $state<number | null>(null); let dragIndex = $state<number | null>(null);
const langLabel = $derived( const langLabel = $derived(
@@ -224,8 +224,14 @@
titleFormula = parseTemplateToFormula(cat.title_template, customVariables); titleFormula = parseTemplateToFormula(cat.title_template, customVariables);
const parsedDesc = parseDescriptionTemplate(cat.description_template); const parsedDesc = parseDescriptionTemplate(cat.description_template);
descSections = parsedDesc.sections; descSections = parsedDesc.sections;
metaTitle = parsedDesc.metaTitle; // Raw stored values only — parseDescriptionTemplate injects English
metaDescription = parsedDesc.metaDescription; // defaults for missing meta fields, which must never be displayed or saved.
const rawTpl = (cat.description_template ?? {}) as {
metaTitle?: unknown;
metaDescription?: unknown;
};
metaTitle = typeof rawTpl.metaTitle === "string" ? rawTpl.metaTitle : "";
metaDescription = typeof rawTpl.metaDescription === "string" ? rawTpl.metaDescription : "";
} }
function onLangChange(code: string) { function onLangChange(code: string) {
@@ -374,10 +380,9 @@
// ----- Description formula editing ----- // ----- Description formula editing -----
function addDescSection() { function addDescSection() {
descSections = [ // New sections start EMPTY — placeholder text guides the user; no
...descSections, // English default instructions are ever inserted into stored content.
{ id: crypto.randomUUID(), type: "p", instructions: getDefaultInstructions("p") } descSections = [...descSections, { id: crypto.randomUUID(), type: "p", instructions: "" }];
];
} }
function removeDescSection(id: string) { function removeDescSection(id: string) {
@@ -385,14 +390,9 @@
} }
function updateDescSection(id: string, updates: Partial<DescriptionSection>) { function updateDescSection(id: string, updates: Partial<DescriptionSection>) {
descSections = descSections.map((section) => { descSections = descSections.map((section) =>
if (section.id !== id) return section; section.id === id ? { ...section, ...updates } : section
const next = { ...section, ...updates }; );
if (updates.type && updates.type !== section.type) {
next.instructions = getDefaultInstructions(updates.type);
}
return next;
});
} }
function reorderDescSections(from: number, to: number) { function reorderDescSections(from: number, to: number) {
@@ -403,11 +403,14 @@
} }
function buildDescriptionTemplate() { function buildDescriptionTemplate() {
if (descSections.length === 0) return null; const keep = descSections.filter((s) => s.instructions.trim() || s.exportId?.trim());
const mt = metaTitle.trim();
const md = metaDescription.trim();
if (keep.length === 0 && !mt && !md) return null;
return { return {
sections: descSections, sections: keep,
metaTitle: metaTitle.trim() || undefined, metaTitle: mt || undefined,
metaDescription: metaDescription.trim() || undefined metaDescription: md || undefined
}; };
} }
@@ -820,19 +823,9 @@
rows="2" rows="2"
placeholder={i18n.t("categories.seoTitlePlaceholder")} placeholder={i18n.t("categories.seoTitlePlaceholder")}
></textarea> ></textarea>
<div class="flex items-center justify-between">
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
{i18n.t("categories.metaTitleHint")} {i18n.t("categories.metaTitleHint")}
</p> </p>
<Button
variant="ghost"
size="sm"
class="text-xs"
onclick={() => (metaTitle = getDefaultMetaTitle())}
>
{i18n.t("categories.resetToDefault")}
</Button>
</div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="metaDescription">{i18n.t("categories.metaDescriptionFormula")}</Label> <Label for="metaDescription">{i18n.t("categories.metaDescriptionFormula")}</Label>
@@ -843,19 +836,9 @@
rows="3" rows="3"
placeholder={i18n.t("categories.seoDescriptionPlaceholder")} placeholder={i18n.t("categories.seoDescriptionPlaceholder")}
></textarea> ></textarea>
<div class="flex items-center justify-between">
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
{i18n.t("categories.metaDescriptionHint")} {i18n.t("categories.metaDescriptionHint")}
</p> </p>
<Button
variant="ghost"
size="sm"
class="text-xs"
onclick={() => (metaDescription = getDefaultMetaDescription())}
>
{i18n.t("categories.resetToDefault")}
</Button>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>