package company import ( "context" "errors" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/security" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // BrandKit stores company brand voice, guidelines, and visual identity. type BrandKit struct { CompanyID uuid.UUID `json:"company_id"` VoiceTone string `json:"voice_tone"` Dos []string `json:"dos"` Donts []string `json:"donts"` PrimaryColor string `json:"primary_color"` SecondaryColor string `json:"secondary_color"` LogoURL string `json:"logo_url"` PreferredTerms []string `json:"preferred_terms"` UpdatedAt time.Time `json:"updated_at"` } // EmptyBrand returns a zero kit for a company (no row yet). func EmptyBrand(companyID uuid.UUID) BrandKit { return BrandKit{ CompanyID: companyID, Dos: []string{}, Donts: []string{}, PreferredTerms: []string{}, } } // LoadBrand returns the company brand kit, or an empty kit when none is saved. func LoadBrand(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (BrandKit, error) { var b BrandKit err := pool.QueryRow(ctx, ` SELECT company_id, voice_tone, COALESCE(dos, '{}'), COALESCE(donts, '{}'), primary_color, secondary_color, logo_url, COALESCE(preferred_terms, '{}'), updated_at FROM company_brand WHERE company_id = $1`, companyID). Scan(&b.CompanyID, &b.VoiceTone, &b.Dos, &b.Donts, &b.PrimaryColor, &b.SecondaryColor, &b.LogoURL, &b.PreferredTerms, &b.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return EmptyBrand(companyID), nil } if err != nil { return BrandKit{}, err } b.Dos = cleanStrings(b.Dos) b.Donts = cleanStrings(b.Donts) b.PreferredTerms = cleanStrings(b.PreferredTerms) return b, nil } // UpsertBrand saves the brand kit for a company. func UpsertBrand(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID, in BrandKit) (BrandKit, error) { in.VoiceTone = security.SanitizePrompt(in.VoiceTone, security.MaxBrandFieldRunes) in.PrimaryColor = security.TruncateRunes(strings.TrimSpace(in.PrimaryColor), 32) in.SecondaryColor = security.TruncateRunes(strings.TrimSpace(in.SecondaryColor), 32) logo, err := ValidateLogoURL(in.LogoURL, companyID) if err != nil { return BrandKit{}, err } in.LogoURL = logo in.Dos = security.SanitizeBrandList(in.Dos) in.Donts = security.SanitizeBrandList(in.Donts) in.PreferredTerms = security.SanitizeBrandList(in.PreferredTerms) var b BrandKit err = pool.QueryRow(ctx, ` INSERT INTO company_brand ( company_id, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now()) ON CONFLICT (company_id) DO UPDATE SET voice_tone = EXCLUDED.voice_tone, dos = EXCLUDED.dos, donts = EXCLUDED.donts, primary_color = EXCLUDED.primary_color, secondary_color = EXCLUDED.secondary_color, logo_url = EXCLUDED.logo_url, preferred_terms = EXCLUDED.preferred_terms, updated_at = now() RETURNING company_id, voice_tone, COALESCE(dos, '{}'), COALESCE(donts, '{}'), primary_color, secondary_color, logo_url, COALESCE(preferred_terms, '{}'), updated_at`, companyID, in.VoiceTone, in.Dos, in.Donts, in.PrimaryColor, in.SecondaryColor, in.LogoURL, in.PreferredTerms, ).Scan(&b.CompanyID, &b.VoiceTone, &b.Dos, &b.Donts, &b.PrimaryColor, &b.SecondaryColor, &b.LogoURL, &b.PreferredTerms, &b.UpdatedAt) if err != nil { return BrandKit{}, err } if b.Dos == nil { b.Dos = []string{} } if b.Donts == nil { b.Donts = []string{} } if b.PreferredTerms == nil { b.PreferredTerms = []string{} } return b, nil } // HasContent reports whether any brand guidance is configured. func (b BrandKit) HasContent() bool { return strings.TrimSpace(b.VoiceTone) != "" || len(b.Dos) > 0 || len(b.Donts) > 0 || len(b.PreferredTerms) > 0 || strings.TrimSpace(b.PrimaryColor) != "" || strings.TrimSpace(b.SecondaryColor) != "" || strings.TrimSpace(b.LogoURL) != "" } // PromptBlock formats brand voice instructions for AI system prompts. // Returns empty string when the kit has no usable voice content. // Kept short (bullet lines) for weak local models / 8k context. func (b BrandKit) PromptBlock() string { var parts []string if t := security.SanitizePrompt(b.VoiceTone, 160); t != "" { parts = append(parts, "- tone: "+t) } dos := security.SanitizeBrandList(b.Dos) donts := security.SanitizeBrandList(b.Donts) terms := security.SanitizeBrandList(b.PreferredTerms) if len(dos) > 4 { dos = dos[:4] } if len(donts) > 4 { donts = donts[:4] } if len(terms) > 6 { terms = terms[:6] } if len(dos) > 0 { parts = append(parts, "- do: "+strings.Join(dos, "; ")) } if len(donts) > 0 { parts = append(parts, "- don't: "+strings.Join(donts, "; ")) } if len(terms) > 0 { parts = append(parts, "- terms: "+strings.Join(terms, ", ")) } if len(parts) == 0 { return "" } block := "Brand:\n" + strings.Join(parts, "\n") return security.TruncateRunes(block, 500) } // FormulaTips returns short brand-aware tips for formula/preview UI. func (b BrandKit) FormulaTips() []string { tips := make([]string, 0, 4) if t := strings.TrimSpace(b.VoiceTone); t != "" { tips = append(tips, "Match brand tone: "+truncateTip(t, 120)) } if len(b.PreferredTerms) > 0 { n := len(b.PreferredTerms) if n > 5 { n = 5 } tips = append(tips, "Prefer terms: "+strings.Join(b.PreferredTerms[:n], ", ")) } if len(b.Donts) > 0 { n := len(b.Donts) if n > 3 { n = 3 } tips = append(tips, "Avoid: "+strings.Join(b.Donts[:n], "; ")) } if len(b.Dos) > 0 { n := len(b.Dos) if n > 3 { n = 3 } tips = append(tips, "Do: "+strings.Join(b.Dos[:n], "; ")) } return tips } func cleanStrings(in []string) []string { if len(in) == 0 { return []string{} } out := make([]string, 0, len(in)) seen := map[string]struct{}{} for _, s := range in { s = strings.TrimSpace(s) if s == "" { continue } key := strings.ToLower(s) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, s) } return out } func truncateTip(s string, max int) string { s = strings.TrimSpace(s) if max <= 0 || len(s) <= max { return s } return strings.TrimSpace(s[:max]) + "…" }