package usecase import ( "context" "strings" "haixun-backend/internal/library/clock" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libmongo "haixun-backend/internal/library/mongo" "haixun-backend/internal/library/placement" "haixun-backend/internal/model/scan_post/domain/entity" domrepo "haixun-backend/internal/model/scan_post/domain/repository" domusecase "haixun-backend/internal/model/scan_post/domain/usecase" "github.com/google/uuid" ) type scanPostUseCase struct { repo domrepo.Repository } func NewUseCase(repo domrepo.Repository) domusecase.UseCase { return &scanPostUseCase{repo: repo} } func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domusecase.ViralReplaceRequest) (int, error) { if err := requireViralActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return 0, err } now := clock.NowUnixNano() entities := make([]entity.ScanPost, 0, len(req.Posts)) for _, item := range req.Posts { entities = append(entities, entity.ScanPost{ ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, LegacyPersonaID: req.PersonaID, Flow: entity.FlowViral, ScanJobID: req.ScanJobID, SearchTag: item.SearchTag, ExternalID: item.ExternalID, Permalink: item.Permalink, Author: item.Author, Text: item.Text, Priority: item.Priority, LikeCount: item.LikeCount, ReplyCount: item.ReplyCount, EngagementScore: item.EngagementScore, PlacementScore: item.PlacementScore, Source: string(item.Source), Replies: toReplyEntities(item.Replies), CreateAt: now, }) } if err := u.repo.ReplaceForViralScan(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.ScanJobID, entities); err != nil { return 0, err } return len(entities), nil } func (u *scanPostUseCase) ClearPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error { if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil { return err } return u.repo.ClearForPlacementScan(ctx, tenantID, ownerUID, brandID, topicID) } func (u *scanPostUseCase) UpsertScanCheckpoint(ctx context.Context, req domusecase.CheckpointRequest) (int, error) { if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil { return 0, err } entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.GraphID, req.ScanJobID, req.Posts) return u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, entities) } func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) { if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil { return 0, err } entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.GraphID, req.ScanJobID, req.Posts) count, err := u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, entities) if err != nil { return 0, err } keep := make([]string, 0, len(entities)) for _, item := range entities { if strings.TrimSpace(item.Permalink) != "" { keep = append(keep, item.Permalink) } } if err := u.repo.PruneScanJobPosts(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.ScanJobID, keep); err != nil { return count, err } return count, nil } func (u *scanPostUseCase) ReplaceFromScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) { if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil { return 0, err } entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.GraphID, req.ScanJobID, req.Posts) if err := u.repo.ReplaceForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ScanJobID, entities); err != nil { return 0, err } return len(entities), nil } func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID, scanJobID string, posts []placement.ScanCandidate) []entity.ScanPost { now := clock.NowUnixNano() entities := make([]entity.ScanPost, 0, len(posts)) for _, item := range posts { if strings.TrimSpace(item.Permalink) == "" { continue } entities = append(entities, entity.ScanPost{ ID: uuid.NewString(), TenantID: tenantID, OwnerUID: ownerUID, BrandID: brandID, TopicID: strings.TrimSpace(topicID), Flow: entity.FlowPlacement, GraphID: graphID, ScanJobID: scanJobID, GraphNodeID: item.GraphNodeID, SearchTag: item.SearchTag, QueryDimension: string(item.QueryDimension), ExternalID: item.ExternalID, Permalink: item.Permalink, Author: item.Author, Text: item.Text, Priority: item.Priority, PlacementScore: item.PlacementScore, ProductFitScore: item.ProductFitScore, SolvedByProduct: item.SolvedByProduct, Source: string(item.Source), OutreachStatus: entity.OutreachStatusPending, PostedAt: strings.TrimSpace(item.PostedAt), Replies: toReplyEntities(item.Replies), CreateAt: now, }) } return entities } func (u *scanPostUseCase) Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*domusecase.ScanPostSummary, error) { if err := requireActor(tenantID, ownerUID, brandID); err != nil { return nil, err } postID = strings.TrimSpace(postID) if postID == "" { return nil, errMissingBrand() } item, err := u.repo.Get(ctx, tenantID, ownerUID, brandID, postID) if err != nil { return nil, err } summary := toSummary(*item) return &summary, nil } func (u *scanPostUseCase) UpdateOutreach(ctx context.Context, req domusecase.UpdateOutreachRequest) (*domusecase.ScanPostSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { return nil, err } postID := strings.TrimSpace(req.PostID) if postID == "" { return nil, errMissingBrand() } status := strings.TrimSpace(req.Status) if status == "" { return nil, app.For(code.Brand).InputMissingRequired("outreach status is required") } item, err := u.repo.UpdateOutreach(ctx, req.TenantID, req.OwnerUID, req.BrandID, postID, entity.OutreachPatch{ Status: status, PublishedReplyID: req.PublishedReplyID, PublishedPermalink: req.PublishedPermalink, }) if err != nil { return nil, err } summary := toSummary(*item) return &summary, nil } func (u *scanPostUseCase) Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error { if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil { return err } postID = strings.TrimSpace(postID) if postID == "" { return app.For(code.Brand).InputMissingRequired("scan post id is required") } return u.repo.Delete(ctx, tenantID, ownerUID, brandID, topicID, postID) } func (u *scanPostUseCase) DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error) { if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil { return 0, err } ids := make([]string, 0, len(postIDs)) seen := map[string]struct{}{} for _, postID := range postIDs { id := strings.TrimSpace(postID) if id == "" { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} ids = append(ids, id) } if len(ids) == 0 { return 0, app.For(code.Brand).InputMissingRequired("post_ids is required") } return u.repo.DeleteMany(ctx, tenantID, ownerUID, brandID, topicID, ids) } func (u *scanPostUseCase) GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*domusecase.ScanPostSummary, error) { if err := requireViralActor(tenantID, ownerUID, personaID); err != nil { return nil, err } postID = strings.TrimSpace(postID) if postID == "" { return nil, errMissingPersona() } item, err := u.repo.GetForPersona(ctx, tenantID, ownerUID, personaID, postID) if err != nil { return nil, err } summary := toSummary(*item) return &summary, nil } func (u *scanPostUseCase) ListForPersona(ctx context.Context, req domusecase.PersonaListRequest) ([]domusecase.ScanPostSummary, error) { if err := requireViralActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } items, err := u.repo.ListForPersona(ctx, req.TenantID, req.OwnerUID, domrepo.PersonaListFilter{ PersonaID: req.PersonaID, Flow: entity.FlowViral, Limit: req.Limit, }) if err != nil { return nil, err } out := make([]domusecase.ScanPostSummary, 0, len(items)) for _, item := range items { out = append(out, toSummary(item)) } return out, nil } func (u *scanPostUseCase) List(ctx context.Context, req domusecase.ListRequest) ([]domusecase.ScanPostSummary, error) { if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil { return nil, err } items, err := u.repo.List(ctx, req.TenantID, req.OwnerUID, domrepo.ListFilter{ BrandID: req.BrandID, TopicID: req.TopicID, Priority: req.Priority, ProductFitMin: req.ProductFitMin, Recent7dOnly: req.Recent7dOnly, Limit: req.Limit, }) if err != nil { return nil, err } out := make([]domusecase.ScanPostSummary, 0, len(items)) for _, item := range items { out = append(out, toSummary(item)) } return out, nil } func requireActor(tenantID, ownerUID, brandID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return errMissingActor() } if strings.TrimSpace(brandID) == "" { return errMissingBrand() } return nil } func requireListActor(tenantID, ownerUID, brandID, topicID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return errMissingActor() } if strings.TrimSpace(topicID) == "" && strings.TrimSpace(brandID) == "" { return errMissingBrand() } return nil } func requireViralActor(tenantID, ownerUID, personaID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return errMissingActor() } if strings.TrimSpace(personaID) == "" { return errMissingPersona() } return nil } func errMissingPersona() error { return app.For(code.Persona).InputMissingRequired("persona_id is required") } func toReplyEntities(replies []placement.ReplyCandidate) []entity.ScanReply { if len(replies) == 0 { return nil } out := make([]entity.ScanReply, 0, len(replies)) for _, reply := range replies { out = append(out, entity.ScanReply{ ExternalID: reply.ExternalID, Author: reply.Author, Text: reply.Text, Permalink: reply.Permalink, LikeCount: reply.LikeCount, PostedAt: reply.PostedAt, }) } return out } func toReplySummaries(replies []entity.ScanReply) []domusecase.ScanReplySummary { if len(replies) == 0 { return nil } out := make([]domusecase.ScanReplySummary, 0, len(replies)) for _, reply := range replies { out = append(out, domusecase.ScanReplySummary{ ExternalID: reply.ExternalID, Author: reply.Author, Text: reply.Text, Permalink: reply.Permalink, LikeCount: reply.LikeCount, PostedAt: reply.PostedAt, }) } return out } func toSummary(item entity.ScanPost) domusecase.ScanPostSummary { return domusecase.ScanPostSummary{ ID: item.ID, BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID), PersonaID: strings.TrimSpace(item.LegacyPersonaID), Flow: item.Flow, GraphNodeID: item.GraphNodeID, SearchTag: item.SearchTag, QueryDimension: item.QueryDimension, ExternalID: item.ExternalID, Permalink: item.Permalink, Author: item.Author, Text: item.Text, Priority: item.Priority, LikeCount: item.LikeCount, ReplyCount: item.ReplyCount, EngagementScore: item.EngagementScore, PlacementScore: item.PlacementScore, ProductFitScore: item.ProductFitScore, SolvedByProduct: item.SolvedByProduct, Source: item.Source, ScanJobID: item.ScanJobID, OutreachStatus: item.OutreachStatus, PublishedReplyID: item.PublishedReplyID, PublishedPermalink: item.PublishedPermalink, OutreachUpdateAt: item.OutreachUpdateAt, PostedAt: item.PostedAt, Replies: toReplySummaries(item.Replies), CreateAt: item.CreateAt, } }