package company import ( "context" "encoding/json" "fmt" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/security" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // LangPromptMap is language-code → prompt text for category / template overrides. type LangPromptMap map[string]string // LocalizedFields holds AI/output fields for one content language. type LocalizedFields struct { ProcessedName string `json:"processed_name,omitempty"` ProcessedDescription string `json:"processed_description,omitempty"` MetaTitle string `json:"meta_title,omitempty"` MetaDescription string `json:"meta_description,omitempty"` EnhanceInputHash string `json:"enhance_input_hash,omitempty"` } // LocalizedContent is language-code → per-language product output fields. type LocalizedContent map[string]LocalizedFields // SanitizeLangPromptMap validates language codes, sanitizes prompts, and drops empties. func SanitizeLangPromptMap(in map[string]string, maxRunes int) (LangPromptMap, error) { out := make(LangPromptMap) if len(in) == 0 { return out, nil } for lang, prompt := range in { code, err := ParseLanguage(lang, false) if err != nil { return nil, fmt.Errorf("unsupported language %q", lang) } p := strings.TrimSpace(security.SanitizePrompt(prompt, maxRunes)) if p == "" { continue } out[code] = p } return out, nil } // PromptForLanguage returns the prompt for lang, or empty if unset. func PromptForLanguage(m LangPromptMap, lang string) string { if len(m) == 0 { return "" } code, err := ParseLanguage(lang, true) if err != nil { code = DefaultLanguage } return strings.TrimSpace(m[code]) } // HasAnyPrompt reports whether any language has a non-empty prompt. func HasAnyPrompt(m LangPromptMap) bool { for _, p := range m { if strings.TrimSpace(p) != "" { return true } } return false } // DecodeLangPromptMap accepts JSON object / map[string]any / map[string]string. func DecodeLangPromptMap(raw any) (LangPromptMap, error) { out := make(LangPromptMap) if raw == nil { return out, nil } switch v := raw.(type) { case LangPromptMap: return SanitizeLangPromptMap(v, security.MaxCampaignPromptRunes) case map[string]string: return SanitizeLangPromptMap(v, security.MaxCampaignPromptRunes) case map[string]any: tmp := make(map[string]string, len(v)) for k, val := range v { s, ok := val.(string) if !ok { return nil, fmt.Errorf("prompt for %q must be a string", k) } tmp[k] = s } return SanitizeLangPromptMap(tmp, security.MaxCampaignPromptRunes) case string: s := strings.TrimSpace(v) if s == "" || s == "{}" { return out, nil } var obj map[string]string if err := json.Unmarshal([]byte(s), &obj); err != nil { return nil, fmt.Errorf("invalid prompt map json") } return SanitizeLangPromptMap(obj, security.MaxCampaignPromptRunes) case []byte: if len(v) == 0 { return out, nil } var obj map[string]string if err := json.Unmarshal(v, &obj); err != nil { return nil, fmt.Errorf("invalid prompt map json") } return SanitizeLangPromptMap(obj, security.MaxCampaignPromptRunes) default: b, err := json.Marshal(raw) if err != nil { return nil, fmt.Errorf("invalid prompt map") } var obj map[string]string if err := json.Unmarshal(b, &obj); err != nil { return nil, fmt.Errorf("invalid prompt map json") } return SanitizeLangPromptMap(obj, security.MaxCampaignPromptRunes) } } // EncodeLangPromptMap marshals a prompt map to JSON bytes (never null). func EncodeLangPromptMap(m LangPromptMap) ([]byte, error) { if m == nil { return []byte("{}"), nil } b, err := json.Marshal(m) if err != nil { return nil, err } return b, nil } // ParseContentLanguages validates and normalizes an ordered language list. // Empty input with allowEmptyAsPrimary yields [DefaultLanguage] or [primary] when primary set. func ParseContentLanguages(raw []string, primary string) ([]string, error) { primaryCode, err := ParseLanguage(primary, true) if err != nil { primaryCode = DefaultLanguage } seen := map[string]struct{}{} out := make([]string, 0, len(raw)+1) add := func(code string) { if _, ok := seen[code]; ok { return } seen[code] = struct{}{} out = append(out, code) } add(primaryCode) for _, r := range raw { code, err := ParseLanguage(r, false) if err != nil { return nil, fmt.Errorf("unsupported language %q", r) } add(code) } return out, nil } // LoadContentLanguages returns companies.content_languages, ensuring primary is first. func LoadContentLanguages(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) []string { primary := LoadLanguage(ctx, pool, companyID) if pool == nil { return []string{primary} } var langs []string err := pool.QueryRow(ctx, ` SELECT COALESCE(content_languages, '{}') FROM companies WHERE id = $1`, companyID).Scan(&langs) if err != nil || len(langs) == 0 { return []string{primary} } parsed, err := ParseContentLanguages(langs, primary) if err != nil { return []string{primary} } return parsed } // FieldsForLanguage returns localized fields for lang (empty struct if missing). func FieldsForLanguage(content LocalizedContent, lang string) LocalizedFields { if len(content) == 0 { return LocalizedFields{} } code, err := ParseLanguage(lang, true) if err != nil { code = DefaultLanguage } return content[code] } // SetFieldsForLanguage upserts fields for one language into content. func SetFieldsForLanguage(content LocalizedContent, lang string, fields LocalizedFields) LocalizedContent { if content == nil { content = LocalizedContent{} } code, err := ParseLanguage(lang, true) if err != nil { code = DefaultLanguage } content[code] = fields return content } // DecodeLocalizedContent parses JSONB / map into LocalizedContent. func DecodeLocalizedContent(raw any) (LocalizedContent, error) { out := LocalizedContent{} if raw == nil { return out, nil } var b []byte switch v := raw.(type) { case []byte: b = v case string: b = []byte(v) default: var err error b, err = json.Marshal(raw) if err != nil { return nil, err } } if len(b) == 0 || string(b) == "null" || string(b) == "{}" { return out, nil } var tmp map[string]LocalizedFields if err := json.Unmarshal(b, &tmp); err != nil { return nil, fmt.Errorf("invalid localized_content") } for lang, fields := range tmp { code, err := ParseLanguage(lang, false) if err != nil { continue } out[code] = fields } return out, nil } // EncodeLocalizedContent marshals localized content (never null). func EncodeLocalizedContent(c LocalizedContent) ([]byte, error) { if c == nil { return []byte("{}"), nil } return json.Marshal(c) } // SyncPrimaryFromLocalized copies primary-language fields onto the denormalized columns shape. func SyncPrimaryFromLocalized(content LocalizedContent, primary string) LocalizedFields { return FieldsForLanguage(content, primary) }