package usecase import ( "context" "crypto/sha256" "encoding/hex" "fmt" "sort" "strings" "apps/backend/internal/module/radar/domain" ) func (s *Service) GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*domain.DemandMap, error) { if ownerUID <= 0 || strings.TrimSpace(productID) == "" { return nil, fmt.Errorf("%w: owner and product are required", domain.ErrValidation) } value, err := s.Repo.GetDemandMap(ctx, ownerUID, productID) if err == nil { return value, nil } if err != domain.ErrNotFound { return nil, err } if s.ProductSource == nil { return nil, fmt.Errorf("%w: product catalog unavailable", domain.ErrNotReady) } product, err := s.ProductSource.GetProduct(ctx, ownerUID, productID) if err != nil { return nil, err } if product == nil { return nil, domain.ErrNotFound } value = baselineDemandMap(product) return s.Repo.SaveDemandMap(ctx, value, 0) } func (s *Service) UpdateDemandMap(ctx context.Context, ownerUID int64, value *domain.DemandMap, expectedVersion int64) (*domain.DemandMap, error) { if value == nil || ownerUID <= 0 || strings.TrimSpace(value.ProductID) == "" { return nil, fmt.Errorf("%w: owner and product are required", domain.ErrValidation) } if value.OwnerUID != 0 && value.OwnerUID != ownerUID { return nil, domain.ErrForbidden } value.OwnerUID = ownerUID if expectedVersion < 1 { return nil, fmt.Errorf("%w: expected map version required", domain.ErrValidation) } current, err := s.GetDemandMap(ctx, ownerUID, value.ProductID) if err != nil { return nil, err } value.DemandInputVersion = current.DemandInputVersion value.SourceBasis = append([]string(nil), current.SourceBasis...) if value.AIEnrichedAt == 0 { value.AIEnrichedAt = current.AIEnrichedAt } value.MapVersion = current.MapVersion value.UpdatedAt = domain.NowNano() return s.Repo.SaveDemandMap(ctx, value, expectedVersion) } func baselineDemandMap(product *ProductCatalogProduct) *domain.DemandMap { mapPhrase := func(text, kind, basisKind, basisText string) domain.DemandMapPhrase { return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: basisText, Origin: "product", Enabled: strings.TrimSpace(text) != ""} } phrases := func(items []string, kind, basisKind string, include bool) []domain.DemandMapPhrase { out := make([]domain.DemandMapPhrase, 0, len(items)) for _, item := range items { raw := strings.TrimSpace(item) if raw == "" { continue } if !include { out = append(out, mapPhrase(raw, kind, basisKind, product.Label)) continue } variants := domain.SearchableTermVariants(raw, true) if len(variants) > domain.MaxSearchableVariantsPerTerm { variants = variants[:domain.MaxSearchableVariantsPerTerm] } for _, term := range variants { out = append(out, mapPhrase(term, kind, basisKind, raw)) } } return out } pain := phrases(product.PainPoints, "pain", "pain_point", true) scenario := phrases([]string{product.ProductContext}, "scenario", "product_context", true) outcomes := phrases(product.MatchTags, "outcome", "match_tag", true) solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability", true) exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude", false) state := "incomplete" if len(pain) > 0 && len(scenario) > 0 && len(solution) > 0 { state = "ready" } version := "demand-" + DemandInputFingerprint(product) return &domain.DemandMap{ ID: "", OwnerUID: product.OwnerUID, ProductID: product.ID, DemandInputVersion: version, MapVersion: 1, State: state, PainPhrases: pain, ScenarioPhrases: scenario, DesiredOutcomes: outcomes, SolutionSignals: solution, ExclusionSignals: exclusions, SourceBasis: []string{"product:" + product.ID}, CustomPhrases: []domain.DemandMapPhrase{}, UpdatedAt: domain.NowNano(), } } // DemandInputFingerprint intentionally excludes display-only product fields // (name, image, price). A wording edit that changes what the provider solves // must create a new input version; a catalog rename must not invalidate a // user's reviewed query plan. func DemandInputFingerprint(product *ProductCatalogProduct) string { if product == nil { return "unknown" } normalize := func(values []string) []string { out := make([]string, 0, len(values)) for _, value := range values { value = strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " ")) if value != "" { out = append(out, value) } } sort.Strings(out) return out } parts := []string{ strings.TrimSpace(product.BrandID), strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(product.ProductContext)), " ")), strings.Join(normalize(product.PainPoints), "\x1f"), strings.Join(normalize(product.MatchTags), "\x1f"), strings.Join(normalize(product.ProviderCapabilityTerms), "\x1f"), strings.Join(normalize(product.ProviderExcludeTerms), "\x1f"), } sum := sha256.Sum256([]byte(strings.Join(parts, "\x1e"))) return hex.EncodeToString(sum[:])[:16] }