diff --git a/src/main/java/kz/konturai/parser/config/TargetingApiConfig.java b/src/main/java/kz/konturai/parser/config/TargetingApiConfig.java new file mode 100644 index 0000000..e7e3dbb --- /dev/null +++ b/src/main/java/kz/konturai/parser/config/TargetingApiConfig.java @@ -0,0 +1,44 @@ +package kz.konturai.parser.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "targeting") +@Data +public class TargetingApiConfig { + private FacebookConfig facebook = new FacebookConfig(); + private TiktokConfig tiktok = new TiktokConfig(); + private Double kztUsdRate = 460.0; + private int insightsSyncIntervalHours = 6; + private AiConfig ai = new AiConfig(); + + @Data + public static class FacebookConfig { + private String appId; + private String appSecret; + private String graphApiVersion = "v19.0"; + private String oauthRedirectUri; + private long rateLimitRetryMs = 60000; + private int maxRetries = 3; + } + + @Data + public static class TiktokConfig { + private String appId; + private String appSecret; + private String oauthRedirectUri; + private String baseUrl = "https://business-api.tiktok.com/open_api/v1.3"; + } + + @Data + public static class AiConfig { + private String audienceModel = "gpt-4o"; + private String creativesModel = "gpt-4o"; + private String budgetModel = "gpt-4o-mini"; + private int maxAudienceTokens = 3000; + private int maxCreativesTokens = 8000; + private int maxBudgetTokens = 2000; + } +} diff --git a/src/main/java/kz/konturai/parser/config/TargetingAsyncConfig.java b/src/main/java/kz/konturai/parser/config/TargetingAsyncConfig.java new file mode 100644 index 0000000..47dc050 --- /dev/null +++ b/src/main/java/kz/konturai/parser/config/TargetingAsyncConfig.java @@ -0,0 +1,24 @@ +package kz.konturai.parser.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class TargetingAsyncConfig { + + @Bean(name = "targetingExecutor") + public Executor targetingExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(3); + executor.setMaxPoolSize(6); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("targeting-async-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/kz/konturai/parser/controller/TargetingCampaignController.java b/src/main/java/kz/konturai/parser/controller/TargetingCampaignController.java new file mode 100644 index 0000000..8ce96a0 --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/TargetingCampaignController.java @@ -0,0 +1,212 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.*; +import kz.konturai.parser.model.MarketingAnalysisV3Document; +import kz.konturai.parser.model.TargetingAudienceProfile; +import kz.konturai.parser.model.TargetingCampaign; +import kz.konturai.parser.repository.MarketingAnalysisV3Repository; +import kz.konturai.parser.service.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/targeting") +@RequiredArgsConstructor +@Slf4j +public class TargetingCampaignController { + + private final TargetingCampaignService campaignService; + private final AudienceSegmentationService audienceService; + private final BudgetOptimizerService budgetOptimizerService; + private final FacebookAdsService facebookAdsService; + private final TikTokAdsService tikTokAdsService; + private final ABTestingService abTestingService; + private final MarketingAnalysisV3Repository analysisRepository; + private final SocialMediaCredentialsService credentialsService; + + @PostMapping("/campaigns") + public ResponseEntity createCampaign( + @RequestHeader("X-User-Id") String userId, + @RequestBody TargetingCampaignRequest request) { + log.info("[Targeting] Create campaign requested by {}", userId); + return ResponseEntity.ok(campaignService.createCampaign(request, userId)); + } + + @GetMapping("/campaigns") + public ResponseEntity> getUserCampaigns( + @RequestHeader("X-User-Id") String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + log.info("[Targeting] Get campaigns for {}", userId); + return ResponseEntity.ok(campaignService.getUserCampaigns(userId, page, size)); + } + + @GetMapping("/campaigns/{id}") + public ResponseEntity getCampaign( + @RequestHeader("X-User-Id") String userId, + @PathVariable String id) { + log.info("[Targeting] Get campaign {} for {}", id, userId); + return ResponseEntity.ok(campaignService.getCampaignById(id, userId)); + } + + @PostMapping("/campaigns/{id}/pause") + public ResponseEntity pauseCampaign( + @RequestHeader("X-User-Id") String userId, + @PathVariable String id) { + log.info("[Targeting] Pause campaign {}", id); + return ResponseEntity.ok(campaignService.pauseCampaign(id, userId)); + } + + @PostMapping("/campaigns/{id}/resume") + public ResponseEntity resumeCampaign( + @RequestHeader("X-User-Id") String userId, + @PathVariable String id) { + log.info("[Targeting] Resume campaign {}", id); + return ResponseEntity.ok(campaignService.resumeCampaign(id, userId)); + } + + @GetMapping("/campaigns/{id}/insights") + public ResponseEntity getInsights( + @RequestHeader("X-User-Id") String userId, + @PathVariable String id, + @RequestParam(defaultValue = "last_7d") String datePreset) { + log.info("[Targeting] Get insights for campaign {}", id); + return ResponseEntity.ok(campaignService.getCampaignInsights(id, userId, datePreset)); + } + + @PostMapping("/campaigns/{id}/sync-insights") + public ResponseEntity syncInsights( + @RequestHeader("X-User-Id") String userId, + @PathVariable String id) { + log.info("[Targeting] Force sync insights for campaign {}", id); + // Authorize + campaignService.getCampaignById(id, userId); + campaignService.syncInsights(id); + return ResponseEntity.ok().build(); + } + + @GetMapping("/campaigns/{id}/ab-test/evaluate") + public ResponseEntity evaluateAbTest( + @RequestHeader("X-User-Id") String userId, + @PathVariable String id) { + log.info("[Targeting] Evaluate AB Test for {}", id); + TargetingCampaign campaign = campaignService.getCampaignById(id, userId); + return ResponseEntity.ok(abTestingService.evaluateABTest(campaign)); + } + + // --- Audience endpoints --- + + @PostMapping("/audience/segment") + public ResponseEntity> segmentAudience( + @RequestHeader("X-User-Id") String userId, + @RequestBody Map body) { + String analysisId = body.get("analysisId"); + log.info("[Targeting] Segment audience for analysis {}", analysisId); + MarketingAnalysisV3Document doc = analysisRepository.findById(analysisId).orElseThrow(); + return ResponseEntity.ok(audienceService.segmentAudience(doc)); + } + + @PostMapping("/audience/estimate") + public ResponseEntity> estimateAudience( + @RequestHeader("X-User-Id") String userId, + @RequestParam String platform, + @RequestBody TargetingAudienceProfile profile) { + log.info("[Targeting] Estimate audience for platform {}", platform); + return ResponseEntity.ok(audienceService.estimateAudienceSize(profile, platform)); + } + + // --- Budget endpoint --- + + @PostMapping("/budget/optimize") + public ResponseEntity optimizeBudget( + @RequestHeader("X-User-Id") String userId, + @RequestBody Map body) { + String campaignId = body.get("campaignId"); + log.info("[Targeting] Optimize budget for campaign {}", campaignId); + TargetingCampaign campaign = campaignService.getCampaignById(campaignId, userId); + MarketingAnalysisV3Document doc = analysisRepository.findById(campaign.getAnalysisId()).orElseThrow(); + return ResponseEntity.ok(budgetOptimizerService.optimizeBudget(campaign, doc)); + } + + // --- Account Selection endpoint --- + + @PostMapping("/account/select") + public ResponseEntity selectAdAccount( + @RequestHeader("X-User-Id") String userId, + @RequestBody Map body) { + String platform = body.get("platform"); + String adAccountId = body.get("adAccountId"); + + log.info("[Targeting] Select ad account {} for platform {} by user {}", adAccountId, platform, userId); + credentialsService.updateAdAccountId(userId, platform, adAccountId); + + return ResponseEntity.ok().build(); + } + + // --- Facebook OAuth --- + + @GetMapping("/facebook/oauth-url") + public ResponseEntity> getFbOauthUrl(@RequestHeader("X-User-Id") String userId) { + log.info("[Targeting] FB OAuth URL requested by {}", userId); + return ResponseEntity.ok(Map.of("url", facebookAdsService.getOAuthUrl(userId))); + } + + @GetMapping("/facebook/callback") + public ResponseEntity fbCallback( + @RequestParam String code, + @RequestParam String state) { + log.info("[Targeting] FB Callback received for state/userId: {}", state); + try { + facebookAdsService.exchangeCodeForToken(code, state); + return ResponseEntity.ok("Facebook Ads account successfully linked!"); + } catch (Exception e) { + log.error("[Targeting] FB Callback error", e); + return ResponseEntity.badRequest().body("Facebook integration failed: " + e.getMessage()); + } + } + + @GetMapping("/facebook/ad-accounts") + public ResponseEntity> getFbAdAccounts( + @RequestHeader("X-User-Id") String userId, + @RequestHeader("Authorization") String token) { + log.info("[Targeting] Get FB Ad Accounts"); + // token passed via header or db; assuming passed directly here for dynamic use or fetched via service inside + return ResponseEntity.ok(facebookAdsService.getAdAccounts(token.replace("Bearer ", ""))); + } + + // --- TikTok OAuth --- + + @GetMapping("/tiktok/oauth-url") + public ResponseEntity> getTtOauthUrl(@RequestHeader("X-User-Id") String userId) { + log.info("[Targeting] TT OAuth URL requested by {}", userId); + return ResponseEntity.ok(Map.of("url", tikTokAdsService.getOAuthUrl(userId))); + } + + @GetMapping("/tiktok/callback") + public ResponseEntity ttCallback( + @RequestParam String auth_code, + @RequestParam String state) { + log.info("[Targeting] TT Callback received for state/userId: {}", state); + try { + tikTokAdsService.exchangeCodeForToken(auth_code, state); + return ResponseEntity.ok("TikTok Ads account successfully linked!"); + } catch (Exception e) { + log.error("[Targeting] TT Callback error", e); + return ResponseEntity.badRequest().body("TikTok integration failed: " + e.getMessage()); + } + } + + @GetMapping("/tiktok/advertisers") + public ResponseEntity>> getTtAdvertisers( + @RequestHeader("X-User-Id") String userId, + @RequestHeader("Authorization") String token) { + log.info("[Targeting] Get TT Advertisers"); + return ResponseEntity.ok(tikTokAdsService.getAdvertisers(token.replace("Bearer ", ""))); + } +} diff --git a/src/main/java/kz/konturai/parser/dto/AudienceOverrideDto.java b/src/main/java/kz/konturai/parser/dto/AudienceOverrideDto.java new file mode 100644 index 0000000..1c32230 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/AudienceOverrideDto.java @@ -0,0 +1,20 @@ +package kz.konturai.parser.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AudienceOverrideDto { + private int ageMin; + private int ageMax; + private List genders; + private List cities; + private List additionalInterests; +} diff --git a/src/main/java/kz/konturai/parser/dto/AudienceSegmentDto.java b/src/main/java/kz/konturai/parser/dto/AudienceSegmentDto.java new file mode 100644 index 0000000..dd04086 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/AudienceSegmentDto.java @@ -0,0 +1,32 @@ +package kz.konturai.parser.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AudienceSegmentDto { + private String segmentName; + private String description; + private int ageMin; + private int ageMax; + private List genders; + private String incomeLevelKzt; + private String platform; + private List facebookInterests; + private List tiktokInterestCategories; + private List behaviors; + private Long estimatedReachAlmaty; + private Long estimatedReachKazakhstan; + private String recommendedMessage; + private String bestTimeToShow; + private String contentFormat; + private Double estimatedCpmKzt; + private int segmentScore; +} diff --git a/src/main/java/kz/konturai/parser/dto/BudgetOptimizationDto.java b/src/main/java/kz/konturai/parser/dto/BudgetOptimizationDto.java new file mode 100644 index 0000000..b880282 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/BudgetOptimizationDto.java @@ -0,0 +1,28 @@ +package kz.konturai.parser.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BudgetOptimizationDto { + private Map platformAllocations; + private Map dailyBudgetPerPlatform; + private Long estimatedImpressions; + private Long estimatedClicks; + private Long estimatedLeads; + private Double estimatedCostPerLead; + private Double expectedRoas; + private String riskLevel; + private String riskRationale; + private List schedulingWindows; + private String recommendedBidStrategy; + private List optimizationTips; +} diff --git a/src/main/java/kz/konturai/parser/dto/FacebookAdAccountDto.java b/src/main/java/kz/konturai/parser/dto/FacebookAdAccountDto.java new file mode 100644 index 0000000..05e47f4 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/FacebookAdAccountDto.java @@ -0,0 +1,20 @@ +package kz.konturai.parser.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FacebookAdAccountDto { + private String id; + private String name; + private int accountStatus; + private String currency; + private String timezoneName; + private String balance; + private String amountSpent; +} diff --git a/src/main/java/kz/konturai/parser/dto/InstagramInsightDto.java b/src/main/java/kz/konturai/parser/dto/InstagramInsightDto.java new file mode 100644 index 0000000..76ef339 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/InstagramInsightDto.java @@ -0,0 +1,29 @@ +package kz.konturai.parser.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InstagramInsightDto { + private Long impressions; + private Long reach; + private Long clicks; + private Double spend; + private Double ctr; + private Double cpm; + private Double cpc; + private Long actions; + private Map costPerActionType; + + // Additional fields for Instagram profile insights + private Long profileViews; + private Long websiteClicks; + private Long followerCount; +} diff --git a/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java b/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java new file mode 100644 index 0000000..ae10d1f --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java @@ -0,0 +1,19 @@ +package kz.konturai.parser.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TargetingAIAnalysisResult { + private List audienceSegments; + private List competitorInsights; + private BudgetOptimizationDto budgetOptimization; + private String aiRecommendationsRationale; +} diff --git a/src/main/java/kz/konturai/parser/dto/TargetingCampaignRequest.java b/src/main/java/kz/konturai/parser/dto/TargetingCampaignRequest.java new file mode 100644 index 0000000..608e4c1 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/TargetingCampaignRequest.java @@ -0,0 +1,33 @@ +package kz.konturai.parser.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TargetingCampaignRequest { + private String analysisId; + private String strategyId; + private String campaignName; + private String objective; + private List platforms; + private Double totalBudgetKzt; + private Double dailyBudgetKzt; + private LocalDateTime startDate; + private LocalDateTime endDate; + + private AudienceOverrideDto audienceOverride; + + private boolean generateAiAudience; + private boolean generateAdCreatives; + private boolean enableAbTesting; + + private List referenceMediaFilenames; +} diff --git a/src/main/java/kz/konturai/parser/dto/TargetingCampaignResponse.java b/src/main/java/kz/konturai/parser/dto/TargetingCampaignResponse.java new file mode 100644 index 0000000..ca01cc7 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/TargetingCampaignResponse.java @@ -0,0 +1,28 @@ +package kz.konturai.parser.dto; + +import kz.konturai.parser.enums.CampaignObjective; +import kz.konturai.parser.model.BudgetConfig; +import kz.konturai.parser.model.PerformanceMetrics; +import kz.konturai.parser.model.TargetingAudienceProfile; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TargetingCampaignResponse { + private String id; + private String name; + private String status; + private CampaignObjective objective; + private BudgetConfig budget; + private TargetingAudienceProfile audience; + private PerformanceMetrics performanceMetrics; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/kz/konturai/parser/dto/TargetingPerformanceDto.java b/src/main/java/kz/konturai/parser/dto/TargetingPerformanceDto.java new file mode 100644 index 0000000..86921bc --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/TargetingPerformanceDto.java @@ -0,0 +1,27 @@ +package kz.konturai.parser.dto; + +import kz.konturai.parser.model.PerformanceMetrics; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TargetingPerformanceDto { + private Double totalSpend; + private Long totalImpressions; + private Long totalClicks; + private Long totalLeads; + private Double avgCtr; + private Double avgCpm; + private Double avgCpc; + private Double avgCpa; + private Double roas; + + private Map platformBreakdown; +} diff --git a/src/main/java/kz/konturai/parser/dto/TikTokCampaignDto.java b/src/main/java/kz/konturai/parser/dto/TikTokCampaignDto.java new file mode 100644 index 0000000..c54bac3 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/TikTokCampaignDto.java @@ -0,0 +1,19 @@ +package kz.konturai.parser.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TikTokCampaignDto { + private Double spend; + private Long impressions; + private Double ctr; + private Double cpm; + private Long videoViews; + private Long conversions; +} diff --git a/src/main/java/kz/konturai/parser/enums/CampaignObjective.java b/src/main/java/kz/konturai/parser/enums/CampaignObjective.java new file mode 100644 index 0000000..b6ef4c2 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/CampaignObjective.java @@ -0,0 +1,10 @@ +package kz.konturai.parser.enums; + +public enum CampaignObjective { + AWARENESS, + TRAFFIC, + ENGAGEMENT, + LEADS, + SALES, + APP_INSTALLS +} diff --git a/src/main/java/kz/konturai/parser/enums/TargetingPlatform.java b/src/main/java/kz/konturai/parser/enums/TargetingPlatform.java new file mode 100644 index 0000000..0684fd5 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/TargetingPlatform.java @@ -0,0 +1,7 @@ +package kz.konturai.parser.enums; + +public enum TargetingPlatform { + INSTAGRAM, + FACEBOOK, + TIKTOK +} diff --git a/src/main/java/kz/konturai/parser/exception/ABTestInsufficientDataException.java b/src/main/java/kz/konturai/parser/exception/ABTestInsufficientDataException.java new file mode 100644 index 0000000..f088fdd --- /dev/null +++ b/src/main/java/kz/konturai/parser/exception/ABTestInsufficientDataException.java @@ -0,0 +1,11 @@ +package kz.konturai.parser.exception; + +public class ABTestInsufficientDataException extends RuntimeException { + public ABTestInsufficientDataException(String message) { + super(message); + } + + public ABTestInsufficientDataException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/kz/konturai/parser/exception/AudienceGenerationException.java b/src/main/java/kz/konturai/parser/exception/AudienceGenerationException.java new file mode 100644 index 0000000..4ac9b21 --- /dev/null +++ b/src/main/java/kz/konturai/parser/exception/AudienceGenerationException.java @@ -0,0 +1,11 @@ +package kz.konturai.parser.exception; + +public class AudienceGenerationException extends RuntimeException { + public AudienceGenerationException(String message) { + super(message); + } + + public AudienceGenerationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/kz/konturai/parser/exception/BudgetOptimizationException.java b/src/main/java/kz/konturai/parser/exception/BudgetOptimizationException.java new file mode 100644 index 0000000..4344fb8 --- /dev/null +++ b/src/main/java/kz/konturai/parser/exception/BudgetOptimizationException.java @@ -0,0 +1,11 @@ +package kz.konturai.parser.exception; + +public class BudgetOptimizationException extends RuntimeException { + public BudgetOptimizationException(String message) { + super(message); + } + + public BudgetOptimizationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/kz/konturai/parser/exception/FacebookPermissionException.java b/src/main/java/kz/konturai/parser/exception/FacebookPermissionException.java new file mode 100644 index 0000000..1256ca2 --- /dev/null +++ b/src/main/java/kz/konturai/parser/exception/FacebookPermissionException.java @@ -0,0 +1,11 @@ +package kz.konturai.parser.exception; + +public class FacebookPermissionException extends RuntimeException { + public FacebookPermissionException(String message) { + super(message); + } + + public FacebookPermissionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/kz/konturai/parser/exception/TargetingCampaignNotFoundException.java b/src/main/java/kz/konturai/parser/exception/TargetingCampaignNotFoundException.java new file mode 100644 index 0000000..66b3370 --- /dev/null +++ b/src/main/java/kz/konturai/parser/exception/TargetingCampaignNotFoundException.java @@ -0,0 +1,11 @@ +package kz.konturai.parser.exception; + +public class TargetingCampaignNotFoundException extends RuntimeException { + public TargetingCampaignNotFoundException(String message) { + super(message); + } + + public TargetingCampaignNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/kz/konturai/parser/exception/TikTokApiException.java b/src/main/java/kz/konturai/parser/exception/TikTokApiException.java new file mode 100644 index 0000000..f450be8 --- /dev/null +++ b/src/main/java/kz/konturai/parser/exception/TikTokApiException.java @@ -0,0 +1,11 @@ +package kz.konturai.parser.exception; + +public class TikTokApiException extends RuntimeException { + public TikTokApiException(String message) { + super(message); + } + + public TikTokApiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/kz/konturai/parser/model/ABTestConfig.java b/src/main/java/kz/konturai/parser/model/ABTestConfig.java new file mode 100644 index 0000000..09d50a5 --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/ABTestConfig.java @@ -0,0 +1,28 @@ +package kz.konturai.parser.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.util.List; + +/** + * A/B testing configuration for a targeting campaign. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ABTestConfig { + + @Field("is_enabled") + private boolean isEnabled; + + @Field("variants") + private List variants; + + @Field("winner_variant_id") + private String winnerVariantId; +} diff --git a/src/main/java/kz/konturai/parser/model/BudgetConfig.java b/src/main/java/kz/konturai/parser/model/BudgetConfig.java new file mode 100644 index 0000000..8691255 --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/BudgetConfig.java @@ -0,0 +1,35 @@ +package kz.konturai.parser.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +/** + * Budget configuration for a targeting campaign. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BudgetConfig { + + @Field("total_budget") + private Double totalBudget; + + @Field("daily_budget") + private Double dailyBudget; + + @Field("currency") + @Builder.Default + private String currency = "KZT"; + + @Field("start_date") + private LocalDateTime startDate; + + @Field("end_date") + private LocalDateTime endDate; +} diff --git a/src/main/java/kz/konturai/parser/model/LocationTarget.java b/src/main/java/kz/konturai/parser/model/LocationTarget.java new file mode 100644 index 0000000..bde88ea --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/LocationTarget.java @@ -0,0 +1,26 @@ +package kz.konturai.parser.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +/** + * Location target for a specific audience. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LocationTarget { + + @Field("city_name") + private String cityName; + + @Field("country") + private String country; + + @Field("radius") + private Double radius; +} diff --git a/src/main/java/kz/konturai/parser/model/PerformanceMetrics.java b/src/main/java/kz/konturai/parser/model/PerformanceMetrics.java new file mode 100644 index 0000000..4f2c99b --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/PerformanceMetrics.java @@ -0,0 +1,41 @@ +package kz.konturai.parser.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +/** + * Performance metrics for a targeting campaign. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PerformanceMetrics { + + @Field("impressions") + private Long impressions; + + @Field("clicks") + private Long clicks; + + @Field("spend") + private Double spend; + + @Field("leads") + private Long leads; + + @Field("ctr") + private Double ctr; + + @Field("cpm") + private Double cpm; + + @Field("cpc") + private Double cpc; + + @Field("roas") + private Double roas; +} diff --git a/src/main/java/kz/konturai/parser/model/SocialMediaCredentials.java b/src/main/java/kz/konturai/parser/model/SocialMediaCredentials.java index 33f6f09..ef5ee13 100644 --- a/src/main/java/kz/konturai/parser/model/SocialMediaCredentials.java +++ b/src/main/java/kz/konturai/parser/model/SocialMediaCredentials.java @@ -21,6 +21,9 @@ public class SocialMediaCredentials { @Field("encrypted_credentials") private Object encryptedCredentials; + @Field("ad_account_id") + private String adAccountId; + @Field("created_at") private LocalDateTime createdAt; @@ -72,6 +75,15 @@ public class SocialMediaCredentials { this.updatedAt = LocalDateTime.now(); } + public String getAdAccountId() { + return adAccountId; + } + + public void setAdAccountId(String adAccountId) { + this.adAccountId = adAccountId; + this.updatedAt = LocalDateTime.now(); + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/src/main/java/kz/konturai/parser/model/TargetingAd.java b/src/main/java/kz/konturai/parser/model/TargetingAd.java new file mode 100644 index 0000000..13efe4f --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/TargetingAd.java @@ -0,0 +1,50 @@ +package kz.konturai.parser.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +/** + * Specific ad definition within an ad set. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TargetingAd { + + @Field("ad_id") + private String adId; // UUID + + @Field("name") + private String name; + + @Field("ad_format") + private String adFormat; // "SINGLE_IMAGE", "VIDEO", "CAROUSEL", "COLLECTION" + + @Field("media_url") + private String mediaUrl; // MinIO filename + + @Field("headline") + private String headline; + + @Field("primary_text") + private String primaryText; // Capitalized text + + @Field("call_to_action") + private String callToAction; // "LEARN_MORE", "SHOP_NOW", "SIGN_UP", "CONTACT_US", "BOOK_NOW" + + @Field("destination_url") + private String destinationUrl; + + @Field("ai_generated_prompt") + private String aiGeneratedPrompt; + + @Field("ab_variant") + private String abVariant; // "A", "B", "C" + + @Field("performance_score") + private int performanceScore; // 0-100 +} diff --git a/src/main/java/kz/konturai/parser/model/TargetingAdSet.java b/src/main/java/kz/konturai/parser/model/TargetingAdSet.java new file mode 100644 index 0000000..f8e6f28 --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/TargetingAdSet.java @@ -0,0 +1,46 @@ +package kz.konturai.parser.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.util.List; + +/** + * Ad set definition within a targeting campaign. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TargetingAdSet { + + @Field("ad_set_id") + private String adSetId; // UUID + + @Field("name") + private String name; + + @Field("platform") + private String platform; + + @Field("budget") + private Double budget; + + @Field("bid_strategy") + private String bidStrategy; // "LOWEST_COST", "TARGET_COST", "BID_CAP" + + @Field("optimization_goal") + private String optimizationGoal; + + @Field("placements") + private List placements; // "FEED", "STORIES", "REELS", "EXPLORE" + + @Field("ads") + private List ads; + + @Field("status") + private String status; +} diff --git a/src/main/java/kz/konturai/parser/model/TargetingAudienceProfile.java b/src/main/java/kz/konturai/parser/model/TargetingAudienceProfile.java new file mode 100644 index 0000000..bf2e05e --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/TargetingAudienceProfile.java @@ -0,0 +1,61 @@ +package kz.konturai.parser.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.util.List; + +/** + * Target audience profile for an ad campaign. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TargetingAudienceProfile { + + @Field("age_min") + private int ageMin; + + @Field("age_max") + private int ageMax; + + @Field("genders") + private List genders; // "MALE", "FEMALE", "ALL" + + @Field("locations") + private List locations; + + @Field("interests") + private List interests; + + @Field("behaviors") + private List behaviors; + + @Field("languages") + private List languages; + + @Field("custom_audiences") + private List customAudiences; // IDs of custom audiences + + @Field("excluded_audiences") + private List excludedAudiences; // IDs of excluded audiences + + @Field("device_types") + private List deviceTypes; // "mobile", "desktop", "tablet" + + @Field("connection_type") + private String connectionType; // "ALL", "WIFI", "CELLULAR" + + @Field("estimated_reach_min") + private Long estimatedReachMin; + + @Field("estimated_reach_max") + private Long estimatedReachMax; + + @Field("audience_score") + private int audienceScore; // 0-100, AI rating +} diff --git a/src/main/java/kz/konturai/parser/model/TargetingCampaign.java b/src/main/java/kz/konturai/parser/model/TargetingCampaign.java new file mode 100644 index 0000000..10ca53f --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/TargetingCampaign.java @@ -0,0 +1,88 @@ +package kz.konturai.parser.model; + +import kz.konturai.parser.dto.StatusHistoryEntry; +import kz.konturai.parser.dto.TargetingAIAnalysisResult; +import kz.konturai.parser.enums.CampaignObjective; +import kz.konturai.parser.enums.TargetingPlatform; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * Main Targeting Campaign entity. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "targeting_campaigns") +public class TargetingCampaign { + + @Id + private String id; + + @Field("user_id") + private String userId; + + @Field("analysis_id") + private String analysisId; + + @Field("strategy_id") + private String strategyId; + + @Field("name") + private String name; + + @Field("objective") + private CampaignObjective objective; + + @Field("status") + private String status; // draft, active, paused, completed, failed + + @Field("platforms") + private List platforms; + + @Field("budget") + private BudgetConfig budget; + + @Field("audience") + private TargetingAudienceProfile audience; + + @Field("ad_sets") + private List adSets; + + @Field("ai_recommendations") + private TargetingAIAnalysisResult aiRecommendations; + + @Field("insights") + private List insights; + + @Field("status_history") + private List statusHistory; + + @Field("performance_metrics") + private PerformanceMetrics performanceMetrics; + + @Field("ab_test_config") + private ABTestConfig abTestConfig; + + @Field("external_ids") + private Map externalIds; // platform -> external id + + @Field("created_at") + private LocalDateTime createdAt; + + @Field("updated_at") + private LocalDateTime updatedAt; + + @Field("completed_at") + private LocalDateTime completedAt; +} diff --git a/src/main/java/kz/konturai/parser/model/TargetingInsight.java b/src/main/java/kz/konturai/parser/model/TargetingInsight.java new file mode 100644 index 0000000..13806fe --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/TargetingInsight.java @@ -0,0 +1,34 @@ +package kz.konturai.parser.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +/** + * Analytical insights for a targeting campaign. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TargetingInsight { + + @Field("recorded_at") + private LocalDateTime recordedAt; + + @Field("platform") + private String platform; + + @Field("metric") + private String metric; + + @Field("value") + private Double value; + + @Field("change_percent") + private Double changePercent; +} diff --git a/src/main/java/kz/konturai/parser/repository/TargetingCampaignRepository.java b/src/main/java/kz/konturai/parser/repository/TargetingCampaignRepository.java new file mode 100644 index 0000000..7f4e379 --- /dev/null +++ b/src/main/java/kz/konturai/parser/repository/TargetingCampaignRepository.java @@ -0,0 +1,26 @@ +package kz.konturai.parser.repository; + +import kz.konturai.parser.model.TargetingCampaign; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface TargetingCampaignRepository extends MongoRepository { + + List findByUserId(String userId); + + Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); + + List findByUserIdAndStatus(String userId, String status); + + List findByAnalysisId(String analysisId); + + List findByStrategyId(String strategyId); + + List findAllByStatusAndUpdatedAtBefore(String status, LocalDateTime date); +} diff --git a/src/main/java/kz/konturai/parser/service/ABTestingService.java b/src/main/java/kz/konturai/parser/service/ABTestingService.java new file mode 100644 index 0000000..3c96e5e --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/ABTestingService.java @@ -0,0 +1,97 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.config.TargetingApiConfig; +import kz.konturai.parser.exception.ABTestInsufficientDataException; +import kz.konturai.parser.model.ABTestConfig; +import kz.konturai.parser.model.TargetingAd; +import kz.konturai.parser.model.TargetingCampaign; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ABTestingService { + + private final OpenAIAnalyticsService openAiService; + private final TargetingApiConfig apiConfig; + private final ObjectMapper objectMapper; + + public void createABTest(TargetingCampaign campaign, String adSetId) { + log.info("Creating AB Test for campaign {} adSet {}", campaign.getId(), adSetId); + ABTestConfig config = ABTestConfig.builder() + .isEnabled(true) + .variants(List.of("A", "B", "C")) + .build(); + campaign.setAbTestConfig(config); + } + + public String evaluateABTest(TargetingCampaign campaign) { + log.info("Evaluating AB Test for campaign {}", campaign.getId()); + + if (campaign.getPerformanceMetrics() == null || campaign.getPerformanceMetrics().getClicks() < 100) { + throw new ABTestInsufficientDataException("Not enough clicks to statistically evaluate the A/B test"); + } + + // Placeholder logic to determine winner + String winnerVariant = "A"; + campaign.getAbTestConfig().setWinnerVariantId(winnerVariant); + return winnerVariant; + } + + public List generateABVariants(String analysisContext, String objective) { + log.info("Generating AB Variants for objective {}", objective); + + try { + String systemPrompt = "Ты — AI копирайтер для рынка Казахстана. Создай 3 варианта одного рекламного объявления с разными подходами: " + + "A: Рациональный (факты, выгоды), B: Эмоциональный (боль клиента), C: Социальное доказательство (кейсы). " + + "Для каждого объявления верни объект с: abVariant (A,B,C), headline (макс 40 симв), primaryText (макс 125 для превью), callToAction (LEARN_MORE, SHOP_NOW, SIGN_UP, CONTACT_US, BOOK_NOW). " + + "Верни JSON массив объектов. Без маркдауна."; + + String instruction = "Сгенерируй A/B/C варианты креативов для кампании с целью " + objective + ". Контекст: " + analysisContext; + + String response = openAiService.generateWithInstructionWithModel( + "Генерация креативов", + instruction, + "ru", + apiConfig.getAi().getCreativesModel(), + systemPrompt, + apiConfig.getAi().getMaxCreativesTokens() + ); + + if (response != null && response.startsWith("```json")) { + response = response.substring(7, response.lastIndexOf("```")).trim(); + } + + return objectMapper.readValue(response, new TypeReference>() {}); + + } catch (Exception e) { + log.error("Failed to generate AB variants", e); + + // Fallback definitions in case of failure + List fallback = new ArrayList<>(); + fallback.add(createFallbackAd("A", "Рациональный заголовок", "Узнайте о наших выгодах. Жмите подробнее.")); + fallback.add(createFallbackAd("B", "Хватит переплачивать!", "Решите проблему сегодня. Жмите подробнее.")); + fallback.add(createFallbackAd("C", "Выбор 1000 клиентов", "Присоединяйтесь к нам. Жмите подробнее.")); + return fallback; + } + } + + private TargetingAd createFallbackAd(String variant, String headline, String text) { + return TargetingAd.builder() + .adId(UUID.randomUUID().toString()) + .abVariant(variant) + .headline(headline) + .primaryText(text) + .callToAction("LEARN_MORE") + .name("Option " + variant) + .build(); + } +} diff --git a/src/main/java/kz/konturai/parser/service/AiTargetologistService.java b/src/main/java/kz/konturai/parser/service/AiTargetologistService.java new file mode 100644 index 0000000..5c3cd2f --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/AiTargetologistService.java @@ -0,0 +1,103 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.dto.AudienceSegmentDto; +import kz.konturai.parser.dto.BudgetOptimizationDto; +import kz.konturai.parser.dto.TargetingCampaignRequest; +import kz.konturai.parser.model.MarketingAnalysisV3Document; +import kz.konturai.parser.model.TargetingAd; +import kz.konturai.parser.model.TargetingAdSet; +import kz.konturai.parser.model.TargetingAudienceProfile; +import kz.konturai.parser.model.TargetingCampaign; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AiTargetologistService { + + private final AudienceSegmentationService audienceService; + private final BudgetOptimizerService budgetOptimizerService; + private final ABTestingService abTestingService; + private final OpenAIAnalyticsService openAiService; + private final ObjectMapper objectMapper; + private final SerperSearchService serperSearchService; + + public TargetingAudienceProfile generateAudienceProfile(String analysisId, TargetingCampaignRequest req, MarketingAnalysisV3Document analysis) { + log.info("Generating Audience Profile for analysis {}", analysisId); + + List aiSegments = audienceService.segmentAudience(analysis); + + TargetingAudienceProfile overrides = req.getAudienceOverride() != null ? TargetingAudienceProfile.builder() + .ageMin(req.getAudienceOverride().getAgeMin()) + .ageMax(req.getAudienceOverride().getAgeMax()) + .genders(req.getAudienceOverride().getGenders()) + .build() : new TargetingAudienceProfile(); + + return audienceService.mergeSegmentsWithExistingAudience(aiSegments, overrides); + } + + public List generateAdCreatives(TargetingCampaign campaign, MarketingAnalysisV3Document analysis) { + log.info("Generating Ad Creatives for campaign {}", campaign.getId()); + List adSets = new ArrayList<>(); + + try { + String context = objectMapper.writeValueAsString(analysis.getResultData()); + String objective = campaign.getObjective() != null ? campaign.getObjective().name() : "TRAFFIC"; + + campaign.getPlatforms().forEach(platform -> { + TargetingAdSet adSet = TargetingAdSet.builder() + .adSetId(UUID.randomUUID().toString()) + .name(platform.name() + " Main Catch") + .platform(platform.name()) + .budget(campaign.getBudget().getDailyBudget() / campaign.getPlatforms().size()) + .bidStrategy("LOWEST_COST") + .optimizationGoal(objective) + .status("DRAFT") + .build(); + + List ads = new ArrayList<>(); + if (campaign.getAbTestConfig() != null && campaign.getAbTestConfig().isEnabled()) { + ads.addAll(abTestingService.generateABVariants(context, objective)); + } else { + List defaultAds = abTestingService.generateABVariants(context, objective); + if (!defaultAds.isEmpty()) ads.add(defaultAds.get(0)); + } + + adSet.setAds(ads); + adSets.add(adSet); + }); + + } catch (Exception e) { + log.error("Error generating Ad creatives", e); + } + + return adSets; + } + + public BudgetOptimizationDto optimizeBudgetDistribution(TargetingCampaign campaign, MarketingAnalysisV3Document analysis) { + return budgetOptimizerService.optimizeBudget(campaign, analysis); + } + + public List analyzeCompetitorAds(String businessNiche, List cities) { + log.info("Analyzing Competitor Ads for niche {}", businessNiche); + try { + String query = businessNiche + " реклама кейсы " + String.join(" ", cities); + String searchResults = serperSearchService.search(query).toString(); + + // In a real scenario, feed searchResults into openAiService to parse out actionable segments + // For now, returning a static mapped list or empty list. + return new ArrayList<>(); + } catch (Exception e) { + log.error("Failed to analyze competitor ads", e); + return new ArrayList<>(); + } + } +} diff --git a/src/main/java/kz/konturai/parser/service/AudienceSegmentationService.java b/src/main/java/kz/konturai/parser/service/AudienceSegmentationService.java new file mode 100644 index 0000000..05a6550 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/AudienceSegmentationService.java @@ -0,0 +1,160 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.config.TargetingApiConfig; +import kz.konturai.parser.dto.AudienceSegmentDto; +import kz.konturai.parser.exception.AudienceGenerationException; +import kz.konturai.parser.model.MarketingAnalysisV3Document; +import kz.konturai.parser.model.TargetingAudienceProfile; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AudienceSegmentationService { + + private final OpenAIAnalyticsService openAiService; + private final TargetingApiConfig apiConfig; + private final ObjectMapper objectMapper; + + // In-memory cache instead of Redis to avoid external dependencies + private final ConcurrentMap cache = new ConcurrentHashMap<>(); + + public List segmentAudience(MarketingAnalysisV3Document analysis) { + String analysisId = analysis.getId(); + String cacheKey = "segments:" + analysisId; + + String cached = cache.get(cacheKey); + if (cached != null) { + try { + return objectMapper.readValue(cached, new TypeReference>() {}); + } catch (JsonProcessingException e) { + log.warn("Failed to parse cached segments, generating new ones..."); + } + } + + try { + String analysisJson = objectMapper.writeValueAsString(analysis.getResultData()); + String systemPrompt = "Ты — эксперт по аудиторному таргетингу с 15-летним опытом в digital-маркетинге на рынке Казахстана. " + + "На основе данных анализа создай МИНИМУМ 5 чётких сегментов аудитории. Верни только JSON-массив объектов без маркдауна. " + + "Для каждого сегмента верни:\n" + + "- segmentName: яркое название\n" + + "- description: детальное описание\n" + + "- ageMin, ageMax: числа\n" + + "- genders: массив строк (MALE, FEMALE)\n" + + "- incomeLevelKzt: оценка дохода\n" + + "- platform: основная платформа (INSTAGRAM, TIKTOK, FACEBOOK)\n" + + "- facebookInterests: массив строк\n" + + "- tiktokInterestCategories: массив строк\n" + + "- behaviors: массив строк\n" + + "- estimatedReachAlmaty: число\n" + + "- estimatedReachKazakhstan: число\n" + + "- recommendedMessage: ключевое сообщение\n" + + "- bestTimeToShow: строка времени\n" + + "- contentFormat: формат\n" + + "- estimatedCpmKzt: число\n" + + "- segmentScore: число (0-100)\n" + + "КОНТЕКСТ: " + analysisJson; + + String response = openAiService.generateWithInstructionWithModel( + "Создай сегменты аудитории.", + systemPrompt, + "ru", + apiConfig.getAi().getAudienceModel(), + systemPrompt, + apiConfig.getAi().getMaxAudienceTokens() + ); + + if (response == null || response.isBlank()) { + throw new AudienceGenerationException("OpenAI returned null/empty response for segmentation"); + } + + // Clean markdown blocks if AI returned them + if (response.startsWith("```json")) { + response = response.substring(7, response.lastIndexOf("```")).trim(); + } + + List segments = objectMapper.readValue(response, new TypeReference>() {}); + segments.sort(Comparator.comparingInt(AudienceSegmentDto::getSegmentScore).reversed()); + + cache.put(cacheKey, objectMapper.writeValueAsString(segments)); + return segments; + + } catch (Exception e) { + log.error("Error generating audience segments", e); + throw new AudienceGenerationException("Failed to segment audience", e); + } + } + + public TargetingAudienceProfile mergeSegmentsWithExistingAudience(List aiSegments, TargetingAudienceProfile manual) { + if (manual == null) { + manual = new TargetingAudienceProfile(); + } + + Set mergedInterests = new HashSet<>(manual.getInterests() != null ? manual.getInterests() : Collections.emptyList()); + Set mergedBehaviors = new HashSet<>(manual.getBehaviors() != null ? manual.getBehaviors() : Collections.emptyList()); + + int minAge = manual.getAgeMin() > 0 ? manual.getAgeMin() : 18; + int maxAge = manual.getAgeMax() > 0 ? manual.getAgeMax() : 65; + + for (AudienceSegmentDto seg : aiSegments) { + if (seg.getFacebookInterests() != null) mergedInterests.addAll(seg.getFacebookInterests()); + if (seg.getBehaviors() != null) mergedBehaviors.addAll(seg.getBehaviors()); + if (seg.getAgeMin() < minAge && manual.getAgeMin() == 0) minAge = seg.getAgeMin(); + if (seg.getAgeMax() > maxAge && manual.getAgeMax() == 0) maxAge = seg.getAgeMax(); + } + + TargetingAudienceProfile result = TargetingAudienceProfile.builder() + .ageMin(minAge) + .ageMax(maxAge) + .genders(manual.getGenders() != null ? manual.getGenders() : List.of("ALL")) + .locations(manual.getLocations() != null ? manual.getLocations() : new ArrayList<>()) + .interests(new ArrayList<>(mergedInterests)) + .behaviors(new ArrayList<>(mergedBehaviors)) + .languages(manual.getLanguages() != null ? manual.getLanguages() : List.of("RU", "KK")) + .customAudiences(manual.getCustomAudiences()) + .excludedAudiences(manual.getExcludedAudiences()) + .deviceTypes(manual.getDeviceTypes() != null ? manual.getDeviceTypes() : List.of("mobile")) + .connectionType(manual.getConnectionType() != null ? manual.getConnectionType() : "ALL") + .audienceScore(85) // Placeholder + .build(); + return result; + } + + public Map estimateAudienceSize(TargetingAudienceProfile profile, String platform) { + long baseSize = switch (platform.toUpperCase()) { + case "INSTAGRAM" -> 6500000L; + case "TIKTOK" -> 8000000L; + case "FACEBOOK" -> 4000000L; + default -> 5000000L; + }; + + // Naive heuristic adjustment based on profile + long minEstimated = (long) (baseSize * 0.1); + long maxEstimated = (long) (baseSize * 0.4); + + profile.setEstimatedReachMin(minEstimated); + profile.setEstimatedReachMax(maxEstimated); + + return Map.of( + "estimated_min", minEstimated, + "estimated_max", maxEstimated + ); + } +} diff --git a/src/main/java/kz/konturai/parser/service/BudgetOptimizerService.java b/src/main/java/kz/konturai/parser/service/BudgetOptimizerService.java new file mode 100644 index 0000000..edbbc36 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/BudgetOptimizerService.java @@ -0,0 +1,71 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.config.TargetingApiConfig; +import kz.konturai.parser.dto.BudgetOptimizationDto; +import kz.konturai.parser.exception.BudgetOptimizationException; +import kz.konturai.parser.model.MarketingAnalysisV3Document; +import kz.konturai.parser.model.TargetingCampaign; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BudgetOptimizerService { + + private final OpenAIAnalyticsService openAiService; + private final TargetingApiConfig apiConfig; + private final ObjectMapper objectMapper; + + public BudgetOptimizationDto optimizeBudget(TargetingCampaign campaign, MarketingAnalysisV3Document analysis) { + log.info("[BudgetOptimizerService] Optimizing budget for campaign {}", campaign.getId()); + + try { + String systemPrompt = "Ты — медиапланер с опытом работы на рынке Казахстана. Верни только JSON ответ без маркдауна. \n" + + "Дано:\n" + + " - Общий бюджет: " + campaign.getBudget().getTotalBudget() + " KZT\n" + + " - Цель: " + campaign.getObjective() + "\n" + + "Рекомендации по рынку Казахстана (используй как базу):\n" + + " - Instagram Feed CPM: 1200-2500 KZT\n" + + " - TikTok CPM: 400-900 KZT\n" + + "Верни JSON со следующей структурой: platformAllocations, dailyBudgetPerPlatform, estimatedImpressions, estimatedClicks, estimatedLeads, estimatedCostPerLead, expectedRoas, riskLevel, riskRationale, schedulingWindows, recommendedBidStrategy, optimizationTips"; + + String instruction = "Рассчитай медиаплан на основе бюджета " + campaign.getBudget().getTotalBudget() + " тнг"; + + String response = openAiService.generateWithInstructionWithModel( + "Анализ ниши", + instruction, + "ru", + apiConfig.getAi().getBudgetModel(), + systemPrompt, + apiConfig.getAi().getMaxBudgetTokens() + ); + + if (response == null || response.isBlank()) { + throw new BudgetOptimizationException("AI returned empty for budget optimization"); + } + if (response.startsWith("```json")) { + response = response.substring(7, response.lastIndexOf("```")).trim(); + } + + return objectMapper.readValue(response, BudgetOptimizationDto.class); + } catch (Exception e) { + log.error("Failed to optimize budget", e); + throw new BudgetOptimizationException("Failed to optimize budget", e); + } + } + + public BudgetOptimizationDto rebalanceBudget(String campaignId, BudgetOptimizationDto existingPlan) { + log.info("[BudgetOptimizerService] Rebalancing budget for campaign {}", campaignId); + // In a real scenario, this fetches actual insights comparing to expected plan. + // For now, returning existing plan setup smoothly. + return existingPlan; + } +} diff --git a/src/main/java/kz/konturai/parser/service/FacebookAdsService.java b/src/main/java/kz/konturai/parser/service/FacebookAdsService.java new file mode 100644 index 0000000..9364d2e --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/FacebookAdsService.java @@ -0,0 +1,318 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.config.TargetingApiConfig; +import kz.konturai.parser.dto.FacebookAdAccountDto; +import kz.konturai.parser.dto.InstagramInsightDto; +import kz.konturai.parser.exception.FacebookPermissionException; +import kz.konturai.parser.exception.FacebookTokenExpiredException; +import kz.konturai.parser.model.BudgetConfig; +import kz.konturai.parser.model.TargetingAd; +import kz.konturai.parser.model.TargetingAdSet; +import kz.konturai.parser.model.TargetingAudienceProfile; +import kz.konturai.parser.model.TargetingCampaign; +import kz.konturai.parser.enums.CampaignObjective; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FacebookAdsService { + + private final TargetingApiConfig apiConfig; + private final WebClient.Builder webClientBuilder; + private final SocialMediaCredentialsService credentialsService; + private final ObjectMapper objectMapper; + + private WebClient webClient; + private final Map>> interestCache = new ConcurrentHashMap<>(); + + @PostConstruct + public void init() { + String baseUrl = "https://graph.facebook.com/" + apiConfig.getFacebook().getGraphApiVersion(); + this.webClient = webClientBuilder.baseUrl(baseUrl).build(); + } + + public List getAdAccounts(String accessToken) { + return executeWithRetry(() -> { + JsonNode response = webClient.get() + .uri("/me/adaccounts?fields=id,name,account_status,currency,timezone_name,balance,amount_spent") + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + List accounts = new ArrayList<>(); + if (response != null && response.has("data")) { + for (JsonNode node : response.get("data")) { + accounts.add(objectMapper.convertValue(node, FacebookAdAccountDto.class)); + } + } + return accounts; + }); + } + + public String createCampaign(String accessToken, String adAccountId, TargetingCampaign campaign) { + return executeWithRetry(() -> { + Map body = new HashMap<>(); + body.put("name", campaign.getName()); + body.put("objective", mapObjective(campaign.getObjective())); + body.put("status", "PAUSED"); + body.put("special_ad_categories", new ArrayList<>()); + + JsonNode response = webClient.post() + .uri("/{ad-account-id}/campaigns", adAccountId) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + return response != null && response.has("id") ? response.get("id").asText() : null; + }); + } + + public String createAdSet(String accessToken, String adAccountId, String facebookCampaignId, + TargetingAdSet adSet, TargetingAudienceProfile audience, BudgetConfig budget) { + return executeWithRetry(() -> { + Map body = new HashMap<>(); + body.put("name", adSet.getName()); + body.put("campaign_id", facebookCampaignId); + body.put("status", "PAUSED"); + + Map targeting = new HashMap<>(); + targeting.put("geo_locations", Map.of("countries", List.of("KZ"))); + if (audience.getAgeMin() > 0) targeting.put("age_min", audience.getAgeMin()); + if (audience.getAgeMax() > 0) targeting.put("age_max", audience.getAgeMax()); + body.put("targeting", targeting); + + body.put("optimization_goal", adSet.getOptimizationGoal()); + body.put("billing_event", "IMPRESSIONS"); + + double budgetInCents = budget.getDailyBudget() / apiConfig.getKztUsdRate() * 100; + body.put("daily_budget", (long) budgetInCents); + + JsonNode response = webClient.post() + .uri("/{ad-account-id}/adsets", adAccountId) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + return response != null && response.has("id") ? response.get("id").asText() : null; + }); + } + + public String createAd(String accessToken, String adAccountId, String facebookAdSetId, TargetingAd ad, String pageId) { + return executeWithRetry(() -> { + // Placeholder for AdCreative creation logic + String creativeId = "creative_placeholder"; + + Map body = new HashMap<>(); + body.put("name", ad.getName()); + body.put("adset_id", facebookAdSetId); + body.put("creative", Map.of("creative_id", creativeId)); + body.put("status", "PAUSED"); + + JsonNode response = webClient.post() + .uri("/{ad-account-id}/ads", adAccountId) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + return response != null && response.has("id") ? response.get("id").asText() : null; + }); + } + + public InstagramInsightDto getInsights(String accessToken, String campaignId, String datePreset) { + return executeWithRetry(() -> { + JsonNode response = webClient.get() + .uri("/{campaign-id}/insights?fields=impressions,reach,clicks,spend,ctr,cpm,cpc,actions,cost_per_action_type&date_preset={date_preset}", campaignId, datePreset) + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + if (response != null && response.has("data") && response.get("data").isArray() && !response.get("data").isEmpty()) { + JsonNode data = response.get("data").get(0); + return InstagramInsightDto.builder() + .impressions(data.has("impressions") ? data.get("impressions").asLong() : 0L) + .reach(data.has("reach") ? data.get("reach").asLong() : 0L) + .clicks(data.has("clicks") ? data.get("clicks").asLong() : 0L) + .spend(data.has("spend") ? data.get("spend").asDouble() : 0.0) + .ctr(data.has("ctr") ? data.get("ctr").asDouble() : 0.0) + .cpm(data.has("cpm") ? data.get("cpm").asDouble() : 0.0) + .cpc(data.has("cpc") ? data.get("cpc").asDouble() : 0.0) + .build(); + } + return new InstagramInsightDto(); + }); + } + + public InstagramInsightDto getInstagramInsights(String accessToken, String instagramAccountId) { + return executeWithRetry(() -> { + JsonNode response = webClient.get() + .uri("/{ig-user-id}/insights?metric=impressions,reach,profile_views,website_clicks,follower_count&period=week", instagramAccountId) + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + return new InstagramInsightDto(); + }); + } + + public List> searchTargetingInterests(String accessToken, String query) { + if (interestCache.containsKey(query)) { + return interestCache.get(query); + } + return executeWithRetry(() -> { + JsonNode response = webClient.get() + .uri("/search?type=adinterest&q={query}&locale=ru_RU", query) + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + List> results = new ArrayList<>(); + if (response != null && response.has("data")) { + for (JsonNode node : response.get("data")) { + results.add(objectMapper.convertValue(node, Map.class)); + } + } + interestCache.put(query, results); + return results; + }); + } + + public void pauseCampaign(String accessToken, String campaignId) { + updateCampaignStatus(accessToken, campaignId, "PAUSED"); + } + + public void resumeCampaign(String accessToken, String campaignId) { + updateCampaignStatus(accessToken, campaignId, "ACTIVE"); + } + + private void updateCampaignStatus(String accessToken, String campaignId, String status) { + executeWithRetry(() -> { + webClient.post() + .uri("/{campaign-id}?status={status}", campaignId, status) + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .bodyToMono(String.class) + .block(); + return null; + }); + } + + public String getOAuthUrl(String userId) { + return "https://www.facebook.com/" + apiConfig.getFacebook().getGraphApiVersion() + "/dialog/oauth?" + + "client_id=" + apiConfig.getFacebook().getAppId() + + "&redirect_uri=" + apiConfig.getFacebook().getOauthRedirectUri() + + "&scope=ads_management,ads_read,instagram_basic,instagram_manage_insights,pages_read_engagement" + + "&state=" + userId + + "&response_type=code"; + } + + public String exchangeCodeForToken(String code, String userId) { + return executeWithRetry(() -> { + JsonNode response = webClient.get() + .uri("/oauth/access_token?client_id={app_id}&client_secret={app_secret}&code={code}&redirect_uri={redirect_uri}", + apiConfig.getFacebook().getAppId(), + apiConfig.getFacebook().getAppSecret(), + code, + apiConfig.getFacebook().getOauthRedirectUri()) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + if (response != null && response.has("access_token")) { + String accessToken = response.get("access_token").asText(); + credentialsService.saveCredentials(userId, "FACEBOOK", accessToken); + return accessToken; + } + throw new RuntimeException("Failed to exchange code for token"); + }); + } + + private String mapObjective(CampaignObjective objective) { + if (objective == null) return "OUTCOME_TRAFFIC"; + return switch (objective) { + case LEADS -> "OUTCOME_LEADS"; + case SALES -> "OUTCOME_SALES"; + case TRAFFIC -> "OUTCOME_TRAFFIC"; + case AWARENESS -> "OUTCOME_AWARENESS"; + case ENGAGEMENT -> "OUTCOME_ENGAGEMENT"; + case APP_INSTALLS -> "OUTCOME_APP_PROMOTION"; + }; + } + + private T executeWithRetry(java.util.function.Supplier action) { + int maxRetries = apiConfig.getFacebook().getMaxRetries(); + long retryDelay = apiConfig.getFacebook().getRateLimitRetryMs(); + int attempt = 0; + + while (true) { + try { + return action.get(); + } catch (WebClientResponseException e) { + handleGraphApiError(e); + attempt++; + if (attempt > maxRetries) { + throw e; + } + log.warn("[Facebook API] Rate limit hit. Retrying in {} ms. Attempt {}/{}", retryDelay, attempt, maxRetries); + try { + Thread.sleep(retryDelay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Thread interrupted during retry delay", ie); + } + } catch (Exception e) { + log.error("[Facebook API] Unexpected error", e); + throw e; + } + } + } + + private void handleGraphApiError(WebClientResponseException e) { + String responseBody = e.getResponseBodyAsString(); + log.error("[Facebook API] Error: Status={}, Response={}", e.getStatusCode(), responseBody); + + try { + JsonNode errorNode = objectMapper.readTree(responseBody).path("error"); + if (!errorNode.isMissingNode()) { + int code = errorNode.path("code").asInt(); + if (code == 190) { + throw new FacebookTokenExpiredException("Facebook token expired or invalid", "Facebook token expired or invalid", 190, 190); + } else if (code == 200 || code == 270) { + throw new FacebookPermissionException("Permission denied for this action"); + } else if (code == 17 || code == 80000 || code == 32 || code == 4) { + // Rate limit, let the caller handle retry + return; + } + } + } catch (Exception parseException) { + log.warn("Failed to parse Facebook API error response"); + } + throw e; + } +} diff --git a/src/main/java/kz/konturai/parser/service/SocialMediaCredentialsService.java b/src/main/java/kz/konturai/parser/service/SocialMediaCredentialsService.java index 52601ee..9985901 100644 --- a/src/main/java/kz/konturai/parser/service/SocialMediaCredentialsService.java +++ b/src/main/java/kz/konturai/parser/service/SocialMediaCredentialsService.java @@ -163,4 +163,27 @@ public class SocialMediaCredentialsService { return repository.findByUserId(userId); } + + /** + * Обновляет выбранный рекламный аккаунт для платформы + * + * @param userId ID пользователя + * @param platform Платформа + * @param adAccountId ID выбранного рекламного кабинета + */ + public void updateAdAccountId(String userId, String platform, String adAccountId) { + if (userId == null || platform == null || adAccountId == null) { + throw new IllegalArgumentException("UserId, platform and adAccountId are required"); + } + + Optional existing = repository.findByUserIdAndPlatform(userId, platform.toLowerCase()); + if (existing.isPresent()) { + SocialMediaCredentials credentials = existing.get(); + credentials.setAdAccountId(adAccountId); + repository.save(credentials); + logger.info("Updated adAccountId for user {} and platform {}", userId, platform); + } else { + throw new IllegalArgumentException("Credentials not found for user " + userId + " and platform " + platform); + } + } } diff --git a/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java b/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java new file mode 100644 index 0000000..2e5a1c8 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java @@ -0,0 +1,316 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.dto.StatusHistoryEntry; +import kz.konturai.parser.dto.TargetingAIAnalysisResult; +import kz.konturai.parser.dto.TargetingCampaignRequest; +import kz.konturai.parser.dto.TargetingPerformanceDto; +import kz.konturai.parser.exception.TargetingCampaignNotFoundException; +import kz.konturai.parser.model.*; +import kz.konturai.parser.repository.MarketingAnalysisV3Repository; +import kz.konturai.parser.repository.TargetingCampaignRepository; +import kz.konturai.parser.enums.CampaignObjective; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TargetingCampaignService { + + private final TargetingCampaignRepository repository; + private final MarketingAnalysisV3Repository analysisRepository; + private final AiTargetologistService aiService; + private final ABTestingService abTestingService; + private final FacebookAdsService facebookAdsService; + private final TikTokAdsService tikTokAdsService; + private final SocialMediaCredentialsService credentialsService; + private final TargetingInsightService insightService; + + public TargetingCampaign createCampaign(TargetingCampaignRequest req, String userId) { + if (req.getAnalysisId() == null || req.getAnalysisId().isBlank()) { + throw new IllegalArgumentException("analysisId is required"); + } + if (req.getTotalBudgetKzt() == null || req.getTotalBudgetKzt() <= 0) { + throw new IllegalArgumentException("Valid budget is required"); + } + + BudgetConfig budget = BudgetConfig.builder() + .totalBudget(req.getTotalBudgetKzt()) + .dailyBudget(req.getDailyBudgetKzt()) + .startDate(req.getStartDate() != null ? req.getStartDate() : LocalDateTime.now()) + .endDate(req.getEndDate() != null ? req.getEndDate() : LocalDateTime.now().plusDays(30)) + .build(); + + TargetingCampaign campaign = TargetingCampaign.builder() + .userId(userId) + .analysisId(req.getAnalysisId()) + .strategyId(req.getStrategyId()) + .name(req.getCampaignName()) + .objective(CampaignObjective.valueOf(req.getObjective())) + .status("draft") + .budget(budget) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .statusHistory(new ArrayList<>()) + .insights(new ArrayList<>()) + .externalIds(new HashMap<>()) + .build(); + + // Map platforms + if (req.getPlatforms() != null) { + List platforms = new ArrayList<>(); + req.getPlatforms().forEach(p -> platforms.add(kz.konturai.parser.enums.TargetingPlatform.valueOf(p))); + campaign.setPlatforms(platforms); + } + + addStatusHistory(campaign, "CREATED", "Campaign draft created, initiating AI pipeline"); + TargetingCampaign saved = repository.save(campaign); + + // Process Async + processAsync(saved.getId(), req, userId); + + return saved; + } + + @Async("targetingExecutor") + public void processAsync(String campaignId, TargetingCampaignRequest req, String userId) { + log.info("Starting async processing for campaign {}", campaignId); + TargetingCampaign campaign = repository.findById(campaignId).orElseThrow(() -> + new TargetingCampaignNotFoundException("Campaign not found async: " + campaignId)); + + try { + MarketingAnalysisV3Document analysis = analysisRepository.findById(campaign.getAnalysisId()).orElse(null); + if (analysis == null) { + failCampaign(campaign, "Analysis Document not found: " + campaign.getAnalysisId()); + return; + } + + // Step 1: AI Recommendations & Audience + addStatusHistory(campaign, "GENERATING_AUDIENCE", "Analyzing market data & segmenting audience"); + TargetingAudienceProfile profile = aiService.generateAudienceProfile(campaign.getAnalysisId(), req, analysis); + campaign.setAudience(profile); + + TargetingAIAnalysisResult aiResult = TargetingAIAnalysisResult.builder().build(); + campaign.setAiRecommendations(aiResult); + + // Step 2: Budget Optimization + addStatusHistory(campaign, "OPTIMIZING_BUDGET", "AI allocating budget efficiently"); + aiResult.setBudgetOptimization(aiService.optimizeBudgetDistribution(campaign, analysis)); + repository.save(campaign); + + // Step 3: Competitor Ads + addStatusHistory(campaign, "ANALYZING_COMPETITORS", "Reviewing competitor creatives"); + List cities = profile.getLocations() != null ? + profile.getLocations().stream().map(LocationTarget::getCityName).toList() : List.of("Алматы"); + aiResult.setCompetitorInsights(aiService.analyzeCompetitorAds("Ниша клиента", cities)); + + // Step 4: Ad Creatives + if (req.isGenerateAdCreatives()) { + addStatusHistory(campaign, "GENERATING_CREATIVES", "Composing Ad Headlines & Texts"); + campaign.setAdSets(aiService.generateAdCreatives(campaign, analysis)); + } + + // Step 5: AB Test + if (req.isEnableAbTesting() && campaign.getAdSets() != null && !campaign.getAdSets().isEmpty()) { + addStatusHistory(campaign, "CONFIGURING_AB_TEST", "Setting up A/B Test environment"); + abTestingService.createABTest(campaign, campaign.getAdSets().get(0).getAdSetId()); + } + + // Step 6: External Platform Publishing + addStatusHistory(campaign, "PUBLISHING", "Pushing to Social Media Ads APIs"); + publishToPlatforms(campaign, userId); + + // Step 7: Done + if ("failed".equals(campaign.getStatus())) { + addStatusHistory(campaign, "FAILED", "Some integrations failed or required credentials missing"); + } else { + campaign.setStatus("active"); + addStatusHistory(campaign, "ACTIVE", "Campaign successfully processed and active"); + } + + campaign.setUpdatedAt(LocalDateTime.now()); + repository.save(campaign); + + } catch (Exception e) { + log.error("Error during async targeting pipeline", e); + failCampaign(campaign, "Pipeline error: " + e.getMessage()); + } + } + + private void publishToPlatforms(TargetingCampaign campaign, String userId) { + boolean allSuccess = true; + + if (campaign.getPlatforms() != null) { + // FB / IG + if (campaign.getPlatforms().contains(kz.konturai.parser.enums.TargetingPlatform.FACEBOOK) || + campaign.getPlatforms().contains(kz.konturai.parser.enums.TargetingPlatform.INSTAGRAM)) { + + String fbToken = credentialsService.getCredentials(userId, "FACEBOOK"); + if (fbToken != null) { + try { + SocialMediaCredentials creds = credentialsService.getUserCredentials(userId).stream() + .filter(c -> "facebook".equalsIgnoreCase(c.getPlatform())) + .findFirst().orElse(null); + String adAccountId = (creds != null && creds.getAdAccountId() != null) + ? creds.getAdAccountId() : "placeholder_ad_account"; + + String fbCampId = facebookAdsService.createCampaign(fbToken, adAccountId, campaign); + if (fbCampId != null) { + campaign.getExternalIds().put("FACEBOOK", fbCampId); + log.info("Created FB Campaign {}", fbCampId); + } + } catch (Exception e) { + log.error("Failed to create FB campaign", e); + allSuccess = false; + } + } else { + log.warn("Missing FB credentials for user {}", userId); + allSuccess = false; + } + } + + // TikTok + if (campaign.getPlatforms().contains(kz.konturai.parser.enums.TargetingPlatform.TIKTOK)) { + String ttToken = credentialsService.getCredentials(userId, "TIKTOK"); + if (ttToken != null) { + try { + SocialMediaCredentials creds = credentialsService.getUserCredentials(userId).stream() + .filter(c -> "tiktok".equalsIgnoreCase(c.getPlatform())) + .findFirst().orElse(null); + String ttAdvId = (creds != null && creds.getAdAccountId() != null) + ? creds.getAdAccountId() : "placeholder_tt_adv"; + + String ttCampId = tikTokAdsService.createCampaign(ttToken, ttAdvId, campaign); + if (ttCampId != null) { + campaign.getExternalIds().put("TIKTOK", ttCampId); + log.info("Created TT Campaign {}", ttCampId); + } + } catch (Exception e) { + log.error("Failed to create TT campaign", e); + allSuccess = false; + } + } else { + log.warn("Missing TT credentials for user {}", userId); + allSuccess = false; + } + } + } + + if (!allSuccess) { + campaign.setStatus("failed"); + } + } + + public TargetingCampaign getCampaignById(String id, String userId) { + TargetingCampaign campaign = repository.findById(id) + .orElseThrow(() -> new TargetingCampaignNotFoundException("Campaign not found: " + id)); + if (!campaign.getUserId().equals(userId)) { + throw new TargetingCampaignNotFoundException("Not authorized for this campaign"); + } + return campaign; + } + + public Page getUserCampaigns(String userId, int page, int size) { + return repository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); + } + + public TargetingCampaign pauseCampaign(String campaignId, String userId) { + TargetingCampaign campaign = getCampaignById(campaignId, userId); + campaign.setStatus("paused"); + + Map ids = campaign.getExternalIds(); + if (ids != null) { + if (ids.containsKey("FACEBOOK")) { + facebookAdsService.pauseCampaign(credentialsService.getCredentials(userId, "FACEBOOK"), ids.get("FACEBOOK")); + } + // Add TikTok pause when needed + } + + addStatusHistory(campaign, "PAUSED", "Campaign manually paused"); + return repository.save(campaign); + } + + public TargetingCampaign resumeCampaign(String campaignId, String userId) { + TargetingCampaign campaign = getCampaignById(campaignId, userId); + campaign.setStatus("active"); + + Map ids = campaign.getExternalIds(); + if (ids != null) { + if (ids.containsKey("FACEBOOK")) { + facebookAdsService.resumeCampaign(credentialsService.getCredentials(userId, "FACEBOOK"), ids.get("FACEBOOK")); + } + // Add TikTok resume when needed + } + + addStatusHistory(campaign, "RESUMED", "Campaign manually resumed"); + return repository.save(campaign); + } + + public TargetingPerformanceDto getCampaignInsights(String campaignId, String userId, String datePreset) { + TargetingCampaign campaign = getCampaignById(campaignId, userId); + // Simple aggregate for the controller + TargetingPerformanceDto dto = new TargetingPerformanceDto(); + dto.setTotalSpend(campaign.getPerformanceMetrics() != null ? campaign.getPerformanceMetrics().getSpend() : 0.0); + return dto; + } + + @Scheduled(cron = "0 0 */6 * * *") // Every 6 hours + public void syncInsightsScheduler() { + log.info("Running scheduled insights sync"); + List activeCampaigns = repository.findAllByStatusAndUpdatedAtBefore("active", LocalDateTime.now()); + for (TargetingCampaign campaign : activeCampaigns) { + try { + syncInsights(campaign.getId()); + } catch (Exception e) { + log.error("Failed to sync insights for {}", campaign.getId(), e); + } + } + } + + public void syncInsights(String campaignId) { + TargetingCampaign campaign = repository.findById(campaignId).orElseThrow(); + List newInsights = insightService.generateTargetingInsights(campaign); + if (campaign.getInsights() == null) campaign.setInsights(new ArrayList<>()); + campaign.getInsights().addAll(newInsights); + + // Accumulate metrics + PerformanceMetrics metrics = campaign.getPerformanceMetrics() != null ? campaign.getPerformanceMetrics() : new PerformanceMetrics(); + for (TargetingInsight ti : newInsights) { + if ("SPEND".equals(ti.getMetric())) metrics.setSpend((metrics.getSpend() != null ? metrics.getSpend() : 0) + ti.getValue()); + } + campaign.setPerformanceMetrics(metrics); + + campaign.setUpdatedAt(LocalDateTime.now()); + repository.save(campaign); + } + + private void failCampaign(TargetingCampaign campaign, String error) { + campaign.setStatus("failed"); + addStatusHistory(campaign, "FAILED", error); + campaign.setUpdatedAt(LocalDateTime.now()); + repository.save(campaign); + } + + private void addStatusHistory(TargetingCampaign campaign, String status, String message) { + if (campaign.getStatusHistory() == null) { + campaign.setStatusHistory(new ArrayList<>()); + } + campaign.getStatusHistory().add(StatusHistoryEntry.builder() + .timestamp(LocalDateTime.now()) + .status(status) + .message(message) + .build()); + } +} diff --git a/src/main/java/kz/konturai/parser/service/TargetingInsightService.java b/src/main/java/kz/konturai/parser/service/TargetingInsightService.java new file mode 100644 index 0000000..0870cac --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/TargetingInsightService.java @@ -0,0 +1,80 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.dto.FacebookAdAccountDto; +import kz.konturai.parser.dto.InstagramInsightDto; +import kz.konturai.parser.dto.TikTokCampaignDto; +import kz.konturai.parser.model.TargetingCampaign; +import kz.konturai.parser.model.TargetingInsight; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TargetingInsightService { + + private final FacebookAdsService facebookAdsService; + private final TikTokAdsService tikTokAdsService; + private final SocialMediaCredentialsService credentialsService; + + public List generateTargetingInsights(TargetingCampaign campaign) { + log.info("Generating insights for campaign {}", campaign.getId()); + List insights = new ArrayList<>(); + Map externalIds = campaign.getExternalIds(); + + if (externalIds == null || externalIds.isEmpty()) { + log.warn("Campaign {} has no external IDs to fetch insights", campaign.getId()); + return insights; + } + + String userId = campaign.getUserId(); + + try { + if (externalIds.containsKey("FACEBOOK") || externalIds.containsKey("INSTAGRAM")) { + String fbToken = credentialsService.getCredentials(userId, "FACEBOOK"); + if (fbToken != null) { + String fbCampaignId = externalIds.getOrDefault("FACEBOOK", externalIds.get("INSTAGRAM")); + InstagramInsightDto fbInsights = facebookAdsService.getInsights(fbToken, fbCampaignId, "last_3d"); + insights.add(buildInsight("FACEBOOK", "CTR", fbInsights.getCtr())); + insights.add(buildInsight("FACEBOOK", "CPM", fbInsights.getCpm())); + insights.add(buildInsight("FACEBOOK", "SPEND", fbInsights.getSpend())); + } + } + + if (externalIds.containsKey("TIKTOK")) { + String ttToken = credentialsService.getCredentials(userId, "TIKTOK"); + if (ttToken != null) { + String ttCampaignId = externalIds.get("TIKTOK"); + String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + // Let's assume advertiser_id is known or saved in credentials, use a placeholder here + String advertiserId = "placeholder_adv_id"; + TikTokCampaignDto ttInsights = tikTokAdsService.getCampaignInsights(ttToken, advertiserId, ttCampaignId, today, today); + insights.add(buildInsight("TIKTOK", "CTR", ttInsights.getCtr())); + insights.add(buildInsight("TIKTOK", "CPM", ttInsights.getCpm())); + insights.add(buildInsight("TIKTOK", "SPEND", ttInsights.getSpend())); + } + } + } catch (Exception e) { + log.error("Failed to generate targeting insights for campaign {}", campaign.getId(), e); + } + + return insights; + } + + private TargetingInsight buildInsight(String platform, String metric, Double value) { + return TargetingInsight.builder() + .platform(platform) + .metric(metric) + .value(value != null ? value : 0.0) + .recordedAt(LocalDateTime.now()) + .changePercent(0.0) // Requires historical comparison + .build(); + } +} diff --git a/src/main/java/kz/konturai/parser/service/TikTokAdsService.java b/src/main/java/kz/konturai/parser/service/TikTokAdsService.java new file mode 100644 index 0000000..3d632f6 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/TikTokAdsService.java @@ -0,0 +1,188 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.config.TargetingApiConfig; +import kz.konturai.parser.dto.TikTokCampaignDto; +import kz.konturai.parser.exception.TikTokApiException; +import kz.konturai.parser.model.TargetingAd; +import kz.konturai.parser.model.TargetingAdSet; +import kz.konturai.parser.model.TargetingAudienceProfile; +import kz.konturai.parser.model.TargetingCampaign; +import kz.konturai.parser.enums.CampaignObjective; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TikTokAdsService { + + private final TargetingApiConfig apiConfig; + private final WebClient.Builder webClientBuilder; + private final SocialMediaCredentialsService credentialsService; + private final ObjectMapper objectMapper; + + private WebClient webClient; + + @PostConstruct + public void init() { + this.webClient = webClientBuilder.baseUrl(apiConfig.getTiktok().getBaseUrl()).build(); + } + + public List> getAdvertisers(String accessToken) { + return executeApiCall(accessToken, "/oauth2/advertiser/get/", Map.of( + "app_id", apiConfig.getTiktok().getAppId(), + "secret", apiConfig.getTiktok().getAppSecret() + ), List.class); + } + + public String createCampaign(String accessToken, String advertiserId, TargetingCampaign campaign) { + Map body = new HashMap<>(); + body.put("advertiser_id", advertiserId); + body.put("campaign_name", campaign.getName()); + body.put("objective_type", mapObjective(campaign.getObjective())); + body.put("budget_mode", "BUDGET_MODE_DAY"); + body.put("budget", campaign.getBudget().getDailyBudget() / apiConfig.getKztUsdRate()); + + JsonNode response = executeApiCall(accessToken, "/campaign/create/", body, JsonNode.class); + return getResponseId(response, "campaign_id"); + } + + public String createAdGroup(String accessToken, String advertiserId, String tiktokCampaignId, + TargetingAdSet adSet, TargetingAudienceProfile audience) { + Map body = new HashMap<>(); + body.put("advertiser_id", advertiserId); + body.put("campaign_id", tiktokCampaignId); + body.put("adgroup_name", adSet.getName()); + body.put("placement_type", "PLACEMENT_TYPE_NORMAL"); + body.put("placements", List.of("PLACEMENT_TIKTOK")); + + // Audience + body.put("location_ids", List.of("6252001")); // Placeholder KZ/Almaty mapping + + body.put("optimization_goal", adSet.getOptimizationGoal()); + body.put("budget_mode", "BUDGET_MODE_DAY"); + body.put("budget", adSet.getBudget() / apiConfig.getKztUsdRate()); + + JsonNode response = executeApiCall(accessToken, "/adgroup/create/", body, JsonNode.class); + return getResponseId(response, "adgroup_id"); + } + + public String createAd(String accessToken, String advertiserId, String tiktokAdGroupId, TargetingAd ad) { + Map body = new HashMap<>(); + body.put("advertiser_id", advertiserId); + body.put("adgroup_id", tiktokAdGroupId); + body.put("ad_name", ad.getName()); + body.put("ad_text", ad.getPrimaryText() != null ? ad.getPrimaryText() : ""); + body.put("call_to_action_id", "DOWNLOAD"); // Placeholder CTA code + // Skipping media logic to simplify + + JsonNode response = executeApiCall(accessToken, "/ad/create/", body, JsonNode.class); + return getResponseId(response, "ad_id"); + } + + public TikTokCampaignDto getCampaignInsights(String accessToken, String advertiserId, String campaignId, + String startDate, String endDate) { + Map body = new HashMap<>(); + body.put("advertiser_id", advertiserId); + body.put("report_type", "BASIC"); + body.put("data_level", "AUCTION_CAMPAIGN"); + body.put("dimensions", List.of("campaign_id", "stat_time_day")); + body.put("metrics", List.of("spend", "impressions", "reach", "clicks", "ctr", "cpm", "conversions")); + body.put("start_date", startDate); + body.put("end_date", endDate); + body.put("filtering", List.of(Map.of("field_name", "campaign_ids", "filter_type", "IN", "filter_value", List.of(campaignId)))); + + JsonNode response = executeApiCall(accessToken, "/report/integrated/get/", body, JsonNode.class); + + return TikTokCampaignDto.builder() + .spend(0.0) // placeholder parsing + .impressions(0L) + .build(); + } + + public String getOAuthUrl(String userId) { + return "https://business-api.tiktok.com/portal/auth?" + + "app_id=" + apiConfig.getTiktok().getAppId() + + "&state=" + userId + + "&redirect_uri=" + apiConfig.getTiktok().getOauthRedirectUri(); + } + + public String exchangeCodeForToken(String authCode, String userId) { + Map body = new HashMap<>(); + body.put("app_id", apiConfig.getTiktok().getAppId()); + body.put("secret", apiConfig.getTiktok().getAppSecret()); + body.put("auth_code", authCode); + body.put("grant_type", "authorization_code"); + + // The token endpoint doesn't require an access token in the header + try { + JsonNode response = webClient.post() + .uri("/oauth2/access_token/") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(JsonNode.class) + .block(); + + if (response != null && response.has("data") && response.get("data").has("access_token")) { + String accessToken = response.get("data").get("access_token").asText(); + credentialsService.saveCredentials(userId, "TIKTOK", accessToken); + return accessToken; + } + throw new TikTokApiException("Failed to extract access_token from TikTok response"); + } catch (WebClientResponseException e) { + log.error("[TikTok API] Token exchange error: {}", e.getResponseBodyAsString()); + throw new TikTokApiException("Failed to exchange code", e); + } + } + + private T executeApiCall(String accessToken, String uri, Object body, Class responseType) { + try { + return webClient.post() + .uri(uri) + .header("Access-Token", accessToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(responseType) + .block(); + } catch (WebClientResponseException e) { + log.error("[TikTok API] HTTP error calling {}: {}", uri, e.getResponseBodyAsString()); + throw new TikTokApiException("TikTok API Call Failed", e); + } catch (Exception e) { + log.error("[TikTok API] Unexpected error calling {}", uri, e); + throw new TikTokApiException("Unexpected TikTok API Error", e); + } + } + + private String getResponseId(JsonNode response, String fieldName) { + if (response != null && response.has("data") && response.get("data").has(fieldName)) { + return response.get("data").get(fieldName).asText(); + } + return null; + } + + private String mapObjective(CampaignObjective objective) { + if (objective == null) return "TRAFFIC"; + return switch (objective) { + case LEADS -> "LEAD_GENERATION"; + case SALES -> "CONVERSIONS"; + case TRAFFIC -> "TRAFFIC"; + case AWARENESS -> "REACH"; + case ENGAGEMENT -> "VIDEO_VIEWS"; + case APP_INSTALLS -> "APP_PROMOTION"; + }; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bcfa537..c8d35e9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -112,4 +112,26 @@ telegram.api.timeout=30000 facebook.api.timeout=30000 facebook.verify.token=konturAI -facebook.page.access.token=EAAaocqgT3JoBQtNeU4uqBrywSRwGmFZBQDZBnd1ynWjX070wAa09QBKebjrd9vyjAZCiJuZAZCU8266VlJYTAfwue20QNvENgM3wpjWNmCrTO0gTMAkmPZBKZBXKvZA8eCA4aZCUhmU2jsBRluYqKUyqpYV3ZCK928OVqLqCNZCUhqU6nUU0xP0DOzk90x58tmxpS2y1i7hjQZDZD \ No newline at end of file +facebook.page.access.token=EAAaocqgT3JoBQtNeU4uqBrywSRwGmFZBQDZBnd1ynWjX070wAa09QBKebjrd9vyjAZCiJuZAZCU8266VlJYTAfwue20QNvENgM3wpjWNmCrTO0gTMAkmPZBKZBXKvZA8eCA4aZCUhmU2jsBRluYqKUyqpYV3ZCK928OVqLqCNZCUhqU6nUU0xP0DOzk90x58tmxpS2y1i7hjQZDZD + +# Targeting Module Configuration +targeting.kzt-usd-rate=460.0 + +targeting.facebook.app-id=${FACEBOOK_APP_ID:dummy-app-id} +targeting.facebook.app-secret=${FACEBOOK_APP_SECRET:dummy-app-secret} +targeting.facebook.oauth-redirect-uri=${FACEBOOK_REDIRECT_URI:http://localhost:8080/api/v1/targeting/facebook/callback} +targeting.facebook.graph-api-version=v19.0 +targeting.facebook.rate-limit-retry-ms=5000 +targeting.facebook.max-retries=3 + +targeting.tiktok.app-id=${TIKTOK_APP_ID:dummy-app-id} +targeting.tiktok.app-secret=${TIKTOK_APP_SECRET:dummy-app-secret} +targeting.tiktok.oauth-redirect-uri=${TIKTOK_REDIRECT_URI:http://localhost:8080/api/v1/targeting/tiktok/callback} +targeting.tiktok.base-url=https://business-api.tiktok.com/open_api/v1.3 + +targeting.ai.audience-model=gpt-4o +targeting.ai.max-audience-tokens=2500 +targeting.ai.budget-model=gpt-4o-mini +targeting.ai.max-budget-tokens=1000 +targeting.ai.creatives-model=gpt-4o +targeting.ai.max-creatives-tokens=2000 \ No newline at end of file