package radarmap import ( "apps/backend/internal/module/radar/domain" "apps/backend/internal/types" ) func DemandMap(m *domain.DemandMap) *types.DemandMapPublic { if m == nil { return nil } phrases := func(in []domain.DemandMapPhrase) []types.DemandMapPhrase { out := make([]types.DemandMapPhrase, 0, len(in)) for _, p := range in { out = append(out, types.DemandMapPhrase{Text: p.Text, Kind: p.Kind, BasisKind: p.BasisKind, BasisText: p.BasisText, Origin: p.Origin, Enabled: p.Enabled}) } return out } return &types.DemandMapPublic{ ProductId: m.ProductID, DemandInputVersion: m.DemandInputVersion, MapVersion: m.MapVersion, State: m.State, PainPhrases: phrases(m.PainPhrases), ScenarioPhrases: phrases(m.ScenarioPhrases), DesiredOutcomes: phrases(m.DesiredOutcomes), SolutionSignals: phrases(m.SolutionSignals), ExclusionSignals: phrases(m.ExclusionSignals), SourceBasis: append([]string(nil), m.SourceBasis...), CustomPhrases: phrases(m.CustomPhrases), AiEnrichedAt: m.AIEnrichedAt, UpdatedAt: m.UpdatedAt, } } func CostPreview(p *domain.CostPreview) *types.CostPreviewPublic { if p == nil { return nil } return &types.CostPreviewPublic{ PreviewId: p.PreviewID, Action: p.Action, KeyMode: p.KeyMode, FixedCredits: p.FixedCredits, MinCredits: p.MinCredits, MaxCredits: p.MaxCredits, SearchCalls: p.SearchCalls, MaxAiCandidates: p.MaxAICandidates, RemainingCredits: p.RemainingCredits, EstimateBasis: p.EstimateBasis, ExpiresAt: p.ExpiresAt, } } /* ServiceProfile 把 domain 檔案轉成 API 形狀。 p 為 nil 代表使用者還沒建檔:回 exists=false 的空殼,而不是 404 —— 表單本來就要能開空的。 但 exists 這個欄位必須誠實,前端用它決定要不要顯示「之後再補」提示。 */ func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic { if p == nil { return &types.ServiceProfilePublic{ Exists: false, Services: []types.ServiceItem{}, Cases: []types.ServiceCasePublic{}, Forbidden: []string{}, Faq: []types.FaqItem{}, ServiceAreas: []string{}, } } services := make([]types.ServiceItem, 0, len(p.Services)) for _, s := range p.Services { services = append(services, types.ServiceItem{ Name: s.Name, PriceMin: s.PriceMin, PriceMax: s.PriceMax, Currency: s.Currency, }) } cases := make([]types.ServiceCasePublic, 0, len(p.Cases)) for _, c := range p.Cases { cases = append(cases, types.ServiceCasePublic{Title: c.Title, Summary: c.Summary, Link: c.Link}) } faq := make([]types.FaqItem, 0, len(p.Faq)) for _, f := range p.Faq { faq = append(faq, types.FaqItem{Question: f.Question, Answer: f.Answer}) } forbidden := p.Forbidden if forbidden == nil { forbidden = []string{} } areas := p.ServiceAreas if areas == nil { areas = []string{} } return &types.ServiceProfilePublic{ Exists: true, Services: services, Cases: cases, Forbidden: forbidden, Faq: faq, ServiceAreas: areas, RemoteOk: p.RemoteOk, Availability: p.Availability, ToneNote: p.ToneNote, UpdatedAt: p.UpdatedAt, } } func Watch(w *domain.RadarWatch) *types.RadarWatchPublic { if w == nil { return nil } terms, excludes, regions := w.Terms, w.ExcludeTerms, w.Regions if terms == nil { terms = []string{} } if excludes == nil { excludes = []string{} } if regions == nil { regions = []string{} } return &types.RadarWatchPublic{ Id: w.ID, Terms: terms, ExcludeTerms: excludes, Regions: regions, Status: w.Status, ContextMode: func() string { if w.ContextMode == "" { return domain.WatchContextGeneric } return w.ContextMode }(), BrandId: w.BrandID, ProductId: w.ProductID, BrandNameSnapshot: w.BrandNameSnapshot, ProductLabelSnapshot: w.ProductLabelSnapshot, ContextBoundAt: w.ContextBoundAt, PauseReason: w.PauseReason, LastSweptAt: w.LastSweptAt, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, FirstSweepTriggered: w.FirstSweepTriggered, } } func WatchList(list []*domain.RadarWatch) []types.RadarWatchPublic { out := make([]types.RadarWatchPublic, 0, len(list)) for _, w := range list { if p := Watch(w); p != nil { out = append(out, *p) } } return out } func Pagination(page, pageSize int, total int64) types.Pagination { if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } tp := int(total) / pageSize if int(total)%pageSize != 0 { tp++ } return types.Pagination{Page: page, PageSize: pageSize, Total: total, TotalPages: tp} } // ServiceProfileInput 把 PUT 的 request 轉成 domain。owner_uid 一律由 logic 從 JWT 帶入。 func ServiceProfileInput(req *types.UpsertServiceProfileReq) *domain.ServiceProfile { if req == nil { return nil } services := make([]domain.ServiceItem, 0, len(req.Services)) for _, s := range req.Services { services = append(services, domain.ServiceItem{ Name: s.Name, PriceMin: s.PriceMin, PriceMax: s.PriceMax, Currency: s.Currency, }) } cases := make([]domain.ServiceCase, 0, len(req.Cases)) for _, c := range req.Cases { cases = append(cases, domain.ServiceCase{Title: c.Title, Summary: c.Summary, Link: c.Link}) } faq := make([]domain.FaqItem, 0, len(req.Faq)) for _, f := range req.Faq { faq = append(faq, domain.FaqItem{Question: f.Question, Answer: f.Answer}) } return &domain.ServiceProfile{ Services: services, Cases: cases, Forbidden: req.Forbidden, Faq: faq, ServiceAreas: req.ServiceAreas, RemoteOk: req.RemoteOk, Availability: req.Availability, ToneNote: req.ToneNote, } } func Opportunity(o *domain.Opportunity) *types.OpportunityPublic { if o == nil { return nil } reasons := make([]types.OpportunityReason, 0, len(o.Reasons)) for _, r := range o.Reasons { reasons = append(reasons, types.OpportunityReason{Dimension: r.Dimension, Score: r.Score, Reason: r.Reason}) } terms := o.MatchedTerms if terms == nil { terms = []string{} } out := &types.OpportunityPublic{ Id: o.ID, WatchId: o.WatchID, Source: o.Source, SourceScoutPostId: o.SourceScoutPostID, ExternalId: o.ExternalID, Permalink: o.Permalink, AuthorHandle: o.AuthorHandle, Text: o.Text, PostedAt: o.PostedAt, Status: o.Status, IntentScore: o.IntentScore, IntentBand: o.IntentBand, Reasons: reasons, RegionDetected: o.RegionDetected, RegionMatch: o.RegionMatch, FreshnessHours: o.FreshnessHours, MatchedService: o.MatchedService, MatchedTerms: terms, RejectReason: o.RejectReason, ContactId: o.ContactID, CreatedAt: o.CreatedAt, PrimaryBrandId: o.PrimaryBrandID, PrimaryProductId: o.PrimaryProductID, PrimaryBrandName: o.PrimaryBrandName, PrimaryProductLabel: o.PrimaryProductLabel, PrimaryProductFitScore: o.PrimaryProductFitScore, PrimaryProductFitBand: o.PrimaryProductFitBand, PrimaryProductOverridden: o.PrimaryProductOverridden, ReviewState: reviewState(o), PreviousReviewState: o.PreviousReviewState, RemovalReason: o.RemovalReason, RemovalNote: o.RemovalNote, RemovedAt: o.RemovedAt, RemovedBy: o.RemovedBy, LastMatchedAt: o.LastMatchedAt, PriorityScore: o.PriorityScore, PriorityBand: o.PriorityBand, PainFitScore: o.PainFitScore, DemandIntentScore: o.DemandIntentScore, EvidenceQualityScore: o.EvidenceQualityScore, FreshnessScore: o.FreshnessScore, DemandEvidence: nonNilStrings(o.DemandEvidence), DemandInputVersion: o.DemandInputVersion, DemandMapVersion: o.DemandMapVersion, } out.ProductMatches = make([]types.ProductMatchPublic, 0, len(o.ProductMatches)) for _, match := range o.ProductMatches { if match == nil { continue } reasons := make([]types.ProductFitReasonPublic, 0, len(match.Reasons)) for _, reason := range match.Reasons { reasons = append(reasons, types.ProductFitReasonPublic{Dimension: reason.Dimension, Score: reason.Score, Reason: reason.Reason, CandidateExcerpt: reason.CandidateExcerpt, ProductBasis: reason.ProductBasis}) } out.ProductMatches = append(out.ProductMatches, types.ProductMatchPublic{BrandId: match.BrandID, ProductId: match.ProductID, BrandNameSnapshot: match.BrandNameSnapshot, ProductLabelSnapshot: match.ProductLabelSnapshot, BrandUpdatedAt: match.BrandUpdatedAt, ProductUpdatedAt: match.ProductUpdatedAt, ProductFitScore: match.ProductFitScore, ProductFitBand: match.ProductFitBand, Eligible: match.Eligible, Excluded: match.Excluded, ExcludeReason: match.ExcludeReason, Reasons: reasons, Risks: nonNilStrings(match.Risks), WatchIds: nonNilStrings(match.WatchIDs), MatchedTerms: nonNilStrings(match.MatchedTerms), MatchedAt: match.MatchedAt}) } if o.Override != nil { out.Override = &types.OpportunityOverride{ FromBand: o.Override.FromBand, ToBand: o.Override.ToBand, FromStatus: o.Override.FromStatus, ToStatus: o.Override.ToStatus, ActorUid: o.Override.ActorUID, At: o.Override.At, } } return out } func reviewState(o *domain.Opportunity) string { if o.ReviewState != "" { return o.ReviewState } switch o.Status { case domain.OppAccepted: return domain.ReviewCompleted case domain.OppDismissed: return domain.ReviewRemoved default: return domain.ReviewPending } } func nonNilStrings(in []string) []string { if in == nil { return []string{} } return in } func OpportunityList(list []*domain.Opportunity) []types.OpportunityPublic { out := make([]types.OpportunityPublic, 0, len(list)) for _, o := range list { if p := Opportunity(o); p != nil { out = append(out, *p) } } return out } func Sweep(s *domain.RadarSweep) *types.RadarSweepPublic { if s == nil { return nil } status := "complete" if s.FailedReason != "" { status = "failed" } else if s.TruncatedCount > 0 || s.BudgetDeferredCount > 0 { status = "partial_budget" } return &types.RadarSweepPublic{ Id: s.ID, WatchId: s.WatchID, JobId: s.JobID, Path: s.Path, HitCount: s.HitCount, JudgedCount: s.JudgedCount, CreatedCount: s.CreatedCount, TruncatedCount: s.TruncatedCount, FailedReason: s.FailedReason, CreditsUsed: s.CreditsUsed, MatchEvaluatedCount: s.MatchEvaluatedCount, MatchMergedCount: s.MatchMergedCount, FitRejectedCount: s.FitRejectedCount, DedupedCount: s.DedupedCount, PrefilterPassCount: s.PrefilterPassCount, PrefilterReviewCount: s.PrefilterReviewCount, PrefilterRejectedCount: s.PrefilterRejectedCount, CachedJudgmentCount: s.CachedJudgmentCount, TombstoneMatchedCount: s.TombstoneMatchedCount, BudgetDeferredCount: s.BudgetDeferredCount, DemandInputVersion: s.DemandInputVersion, DemandMapVersion: s.DemandMapVersion, CreditSearch: s.CreditSearch, CreditDemandMap: s.CreditDemandMap, CreditJudge: s.CreditJudge, CreditReply: s.CreditReply, SweepStatus: status, StartedAt: s.StartedAt, EndedAt: s.EndedAt, } } func SweepList(list []*domain.RadarSweep) []types.RadarSweepPublic { out := make([]types.RadarSweepPublic, 0, len(list)) for _, s := range list { if p := Sweep(s); p != nil { out = append(out, *p) } } return out } func Reply(r *domain.ReplyVariant) *types.ReplyVariantPublic { if r == nil { return nil } return &types.ReplyVariantPublic{ Id: r.ID, OpportunityId: r.OpportunityID, Variant: r.Variant, Text: r.Text, UsedAt: r.UsedAt, SentChannel: r.SentChannel, OutboxId: r.OutboxID, CreatedAt: r.CreatedAt, } } func ReplyList(list []*domain.ReplyVariant) []types.ReplyVariantPublic { out := make([]types.ReplyVariantPublic, 0, len(list)) for _, r := range list { if p := Reply(r); p != nil { out = append(out, *p) } } return out }