Files
descrybe/apps/api/internal/company/brand.go
T

212 lines
6.2 KiB
Go
Raw Normal View History

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]) + "…"
}