From 34c1f09e0184dd978d688dc39c21fefd47679666 Mon Sep 17 00:00:00 2001 From: Grae Jones Date: Sat, 14 Mar 2026 13:50:09 -0700 Subject: [PATCH] Initial import into Gitea --- AdPlatformServers.sln | 30 + Creative/Configuration/CreativeConfig.cs | 74 ++ Creative/Controllers/InternalController.cs | 48 ++ Creative/Creative.csproj | 23 + Creative/Creative.zip | Bin 0 -> 20885 bytes Creative/Models/CreativeModels.cs | 206 ++++++ Creative/Program.cs | 96 +++ Creative/Properties/launchSettings.json | 15 + Creative/Security/InternalAuthFilter.cs | 50 ++ Creative/Services/CopyGeneratorService.cs | 233 +++++++ Creative/Services/CreativeService.cs | 230 +++++++ Creative/Services/ImageGeneratorService.cs | 414 +++++++++++ Creative/Services/ScraperService.cs | 161 +++++ Creative/appsettings.Development.json | 8 + Creative/appsettings.json | 25 + Gateway/Controllers/AuthController.cs | 43 +- .../CampaignIntelligenceController.cs | 185 +++++ .../Controllers/ClientDocumentController.cs | 212 ++++++ Gateway/Controllers/DemographicsController.cs | 179 +++++ Gateway/Controllers/ExecutionController.cs | 15 +- Gateway/Controllers/ForecastController.cs | 96 +++ Gateway/Controllers/InitiativeController.cs | 502 ++++++++++++++ Gateway/Controllers/MetricSyncController.cs | 62 ++ .../Controllers/RecommendationController.cs | 153 +++++ Gateway/Controllers/WizardController.cs | 301 ++++++++ Gateway/Data/SqlNames.cs | 34 +- Gateway/Gateway.csproj | 10 +- Gateway/Migrations/001_ChannelConfig.sql | 321 +++++++++ Gateway/Migrations/007_ProviderStatusMap.sql | 257 +++++++ Gateway/Migrations/SecurityHardening.sql | 174 +++++ Gateway/Models/ForecastModels.cs | 137 ++++ Gateway/Models/MultiChannelConfig.cs | 109 +++ Gateway/Program.cs | 122 +++- Gateway/Security/AuthorizationGuard.cs | 396 +++++++++++ Gateway/Security/ClientAuthMiddleware.cs | 15 +- Gateway/Security/ClientContext.cs | 67 +- .../Security/MultiProviderAuthMiddleware.cs | 55 +- Gateway/Services/ChannelConfigService.cs | 299 ++++++++ Gateway/Services/ExecutionService.cs | 216 ++++-- Gateway/Services/ForecastService.cs | 478 +++++++++++++ Gateway/Services/ImageStorageService.cs | 353 ++++++++++ Gateway/Services/InitiativeLaunchService.cs | 553 +++++++++++++++ Gateway/Services/IntelligenceApiClient.cs | 214 ++++++ Gateway/Services/MetricSyncService.cs | 339 +++++++++ Gateway/Services/ProviderStatusNormalizer.cs | 160 +++++ Gateway/appsettings.json | 28 + GoogleApi/GOOGLE_ADS_SETUP.md | 259 ------- GoogleApi/GoogleApi.csproj | 4 +- GoogleApi/Models/AudienceModels.cs | 58 ++ GoogleApi/Models/ForecastModels.cs | 59 ++ GoogleApi/Models/OperationPayloads.cs | 19 + GoogleApi/Program.cs | 3 + GoogleApi/Services/AudienceService.cs | 318 +++++++++ GoogleApi/Services/GoogleAdsService.cs | 126 +++- GoogleApi/Services/KeywordForecastService.cs | 219 ++++++ GoogleApi/Services/ReportingService.cs | 382 +++++++++++ .../Controllers/DemographicsController.cs | 48 ++ .../Controllers/InternalController.cs | 122 ++++ .../SpendDistributionController.cs | 67 ++ .../Engines/DemographicsAnalyzer.cs | 91 +++ IntelligenceApi/Engines/EngineRouter.cs | 75 ++ .../Engines/Franchisee/FranchiseeEngine.cs | 59 ++ .../Engines/Franchisor/FranchisorEngine.cs | 58 ++ .../Engines/General/GeneralEngine.cs | 478 +++++++++++++ .../Engines/ISpendDistributionEngine.cs | 35 + IntelligenceApi/IntelligenceAPI.http | 6 + IntelligenceApi/IntelligenceApi.csproj | 10 + IntelligenceApi/Models/DemographicsModels.cs | 75 ++ .../Models/SpendDistributionModels.cs | 117 ++++ IntelligenceApi/Program.cs | 105 +++ .../Properties/launchSettings.json | 52 ++ IntelligenceApi/appsettings.Development.json | 9 + IntelligenceApi/appsettings.json | 9 + .../Admin/AdminCampaignsController.cs | 38 ++ .../Admin/AdminClientActivityController.cs | 31 + .../Admin/AdminClientDocumentsController.cs | 188 +++++ .../Admin/AdminClientUsersController.cs | 84 +++ .../Admin/AdminClientsController.cs | 371 ++++++++-- .../Controllers/Admin/AdminControllerBase.cs | 9 +- .../Controllers/Admin/AdminHelpController.cs | 50 ++ .../Admin/AdminMetricSyncController.cs | 112 +++ .../Admin/AdminModifiersController.cs | 84 +++ .../Admin/AdminObjectiveMappingController.cs | 120 ++++ .../Admin/AdminRecommendationsController.cs | 179 +++++ .../Admin/AdminSessionsController.cs | 17 +- .../Admin/AdminTemplateConfigController.cs | 220 ++++++ .../Admin/AdminTemplatesController.cs | 115 ++++ .../Controllers/Admin/AdminUsersController.cs | 140 ---- .../Controllers/Admin/DocumentController.cs | 177 +++++ .../Controllers/AdminReportingController.cs | 67 ++ Management/Controllers/HelpController.cs | 67 ++ .../Controllers/MonitoringController.cs | 54 +- .../Controllers/WeatherForecastController.cs | 33 - Management/Management.csproj | 12 +- Management/Program.cs | 15 +- Management/SQL/spAdminClients.sql | 181 ----- Management/SQL/spAdminSessions.sql | 111 --- Management/SQL/spAdminUsers.sql | 288 -------- Management/SQL/spMonitoring.sql | 106 --- Management/SQL/spOnboarding.sql | 151 ----- .../Security/ActivityLoggingMiddleware.cs | 119 ++++ Management/Security/ClientAuthMiddleware.cs | 124 +++- Management/Security/ClientContext.cs | 14 +- Management/Services/GraphService.cs | 36 + Management/Services/RegistrationClient.cs | 150 ++++ Management/WeatherForecast.cs | 13 - Management/appsettings.json | 40 +- MetaApi/Configuration/MetaConfig.cs | 90 +++ MetaApi/Controllers/InternalController.cs | 83 +++ MetaApi/GATEWAY_INTEGRATION.md | 179 +++++ MetaApi/MetaApi.csproj | 23 + MetaApi/MetaApi.http | 6 + MetaApi/Models/OperationPayloads.cs | 205 ++++++ MetaApi/Models/ProviderModels.cs | 96 +++ MetaApi/Program.cs | 65 ++ MetaApi/Properties/launchSettings.json | 52 ++ MetaApi/README.md | 102 +++ MetaApi/Security/InternalAuthFilter.cs | 58 ++ MetaApi/Services/MetaGraphClient.cs | 229 +++++++ MetaApi/Services/MetaMarketingService.cs | 528 +++++++++++++++ MetaApi/appsettings.Development.json | 8 + MetaApi/appsettings.json | 19 + Registration/Data/IRegistrationDataService.cs | 64 ++ Registration/Data/SqlDataService.cs | 74 ++ Registration/Data/SqlService.cs | 82 +++ .../Functions/RegistrationFunctions.cs | 218 ++++++ Registration/Mock/MockDataService.cs | 186 +++++ Registration/Program.cs | 36 + .../appInsights1.arm.json | 67 ++ .../profile.arm.json | 173 +++++ .../storage1.arm.json | 70 ++ Registration/Properties/launchSettings.json | 9 + .../Properties/serviceDependencies.json | 12 + .../Properties/serviceDependencies.local.json | 11 + ...es.usim-adp-registration - Zip Deploy.json | 14 + Registration/README.md | 91 +++ Registration/Registration.csproj | 34 + Registration/host.json | 29 + Registration/local.settings.json | 16 + TikTokApi/Configuration/TikTokConfig.cs | 95 +++ TikTokApi/Controllers/InternalController.cs | 83 +++ TikTokApi/GATEWAY_INTEGRATION.md | 179 +++++ TikTokApi/Models/OperationPayloads.cs | 326 +++++++++ TikTokApi/Models/ProviderModels.cs | 96 +++ TikTokApi/Program.cs | 66 ++ TikTokApi/Properties/launchSettings.json | 52 ++ TikTokApi/README.md | 299 ++++++++ TikTokApi/Security/InternalAuthFilter.cs | 58 ++ TikTokApi/Services/TikTokApiClient.cs | 257 +++++++ TikTokApi/Services/TikTokMarketingService.cs | 641 ++++++++++++++++++ TikTokApi/TikTokApi.csproj | 23 + TikTokApi/TikTokApi.http | 6 + TikTokApi/appsettings.Development.json | 8 + TikTokApi/appsettings.json | 19 + 154 files changed, 17666 insertions(+), 1548 deletions(-) create mode 100644 Creative/Configuration/CreativeConfig.cs create mode 100644 Creative/Controllers/InternalController.cs create mode 100644 Creative/Creative.csproj create mode 100644 Creative/Creative.zip create mode 100644 Creative/Models/CreativeModels.cs create mode 100644 Creative/Program.cs create mode 100644 Creative/Properties/launchSettings.json create mode 100644 Creative/Security/InternalAuthFilter.cs create mode 100644 Creative/Services/CopyGeneratorService.cs create mode 100644 Creative/Services/CreativeService.cs create mode 100644 Creative/Services/ImageGeneratorService.cs create mode 100644 Creative/Services/ScraperService.cs create mode 100644 Creative/appsettings.Development.json create mode 100644 Creative/appsettings.json create mode 100644 Gateway/Controllers/CampaignIntelligenceController.cs create mode 100644 Gateway/Controllers/ClientDocumentController.cs create mode 100644 Gateway/Controllers/DemographicsController.cs create mode 100644 Gateway/Controllers/ForecastController.cs create mode 100644 Gateway/Controllers/InitiativeController.cs create mode 100644 Gateway/Controllers/MetricSyncController.cs create mode 100644 Gateway/Controllers/RecommendationController.cs create mode 100644 Gateway/Controllers/WizardController.cs create mode 100644 Gateway/Migrations/001_ChannelConfig.sql create mode 100644 Gateway/Migrations/007_ProviderStatusMap.sql create mode 100644 Gateway/Migrations/SecurityHardening.sql create mode 100644 Gateway/Models/ForecastModels.cs create mode 100644 Gateway/Models/MultiChannelConfig.cs create mode 100644 Gateway/Security/AuthorizationGuard.cs create mode 100644 Gateway/Services/ChannelConfigService.cs create mode 100644 Gateway/Services/ForecastService.cs create mode 100644 Gateway/Services/ImageStorageService.cs create mode 100644 Gateway/Services/InitiativeLaunchService.cs create mode 100644 Gateway/Services/IntelligenceApiClient.cs create mode 100644 Gateway/Services/MetricSyncService.cs create mode 100644 Gateway/Services/ProviderStatusNormalizer.cs delete mode 100644 GoogleApi/GOOGLE_ADS_SETUP.md create mode 100644 GoogleApi/Models/AudienceModels.cs create mode 100644 GoogleApi/Models/ForecastModels.cs create mode 100644 GoogleApi/Services/AudienceService.cs create mode 100644 GoogleApi/Services/KeywordForecastService.cs create mode 100644 GoogleApi/Services/ReportingService.cs create mode 100644 IntelligenceApi/Controllers/DemographicsController.cs create mode 100644 IntelligenceApi/Controllers/InternalController.cs create mode 100644 IntelligenceApi/Controllers/SpendDistributionController.cs create mode 100644 IntelligenceApi/Engines/DemographicsAnalyzer.cs create mode 100644 IntelligenceApi/Engines/EngineRouter.cs create mode 100644 IntelligenceApi/Engines/Franchisee/FranchiseeEngine.cs create mode 100644 IntelligenceApi/Engines/Franchisor/FranchisorEngine.cs create mode 100644 IntelligenceApi/Engines/General/GeneralEngine.cs create mode 100644 IntelligenceApi/Engines/ISpendDistributionEngine.cs create mode 100644 IntelligenceApi/IntelligenceAPI.http create mode 100644 IntelligenceApi/IntelligenceApi.csproj create mode 100644 IntelligenceApi/Models/DemographicsModels.cs create mode 100644 IntelligenceApi/Models/SpendDistributionModels.cs create mode 100644 IntelligenceApi/Program.cs create mode 100644 IntelligenceApi/Properties/launchSettings.json create mode 100644 IntelligenceApi/appsettings.Development.json create mode 100644 IntelligenceApi/appsettings.json create mode 100644 Management/Controllers/Admin/AdminCampaignsController.cs create mode 100644 Management/Controllers/Admin/AdminClientActivityController.cs create mode 100644 Management/Controllers/Admin/AdminClientDocumentsController.cs create mode 100644 Management/Controllers/Admin/AdminClientUsersController.cs create mode 100644 Management/Controllers/Admin/AdminHelpController.cs create mode 100644 Management/Controllers/Admin/AdminMetricSyncController.cs create mode 100644 Management/Controllers/Admin/AdminModifiersController.cs create mode 100644 Management/Controllers/Admin/AdminObjectiveMappingController.cs create mode 100644 Management/Controllers/Admin/AdminRecommendationsController.cs create mode 100644 Management/Controllers/Admin/AdminTemplateConfigController.cs create mode 100644 Management/Controllers/Admin/AdminTemplatesController.cs delete mode 100644 Management/Controllers/Admin/AdminUsersController.cs create mode 100644 Management/Controllers/Admin/DocumentController.cs create mode 100644 Management/Controllers/AdminReportingController.cs create mode 100644 Management/Controllers/HelpController.cs delete mode 100644 Management/Controllers/WeatherForecastController.cs delete mode 100644 Management/SQL/spAdminClients.sql delete mode 100644 Management/SQL/spAdminSessions.sql delete mode 100644 Management/SQL/spAdminUsers.sql delete mode 100644 Management/SQL/spMonitoring.sql delete mode 100644 Management/SQL/spOnboarding.sql create mode 100644 Management/Security/ActivityLoggingMiddleware.cs create mode 100644 Management/Services/GraphService.cs create mode 100644 Management/Services/RegistrationClient.cs delete mode 100644 Management/WeatherForecast.cs create mode 100644 MetaApi/Configuration/MetaConfig.cs create mode 100644 MetaApi/Controllers/InternalController.cs create mode 100644 MetaApi/GATEWAY_INTEGRATION.md create mode 100644 MetaApi/MetaApi.csproj create mode 100644 MetaApi/MetaApi.http create mode 100644 MetaApi/Models/OperationPayloads.cs create mode 100644 MetaApi/Models/ProviderModels.cs create mode 100644 MetaApi/Program.cs create mode 100644 MetaApi/Properties/launchSettings.json create mode 100644 MetaApi/README.md create mode 100644 MetaApi/Security/InternalAuthFilter.cs create mode 100644 MetaApi/Services/MetaGraphClient.cs create mode 100644 MetaApi/Services/MetaMarketingService.cs create mode 100644 MetaApi/appsettings.Development.json create mode 100644 MetaApi/appsettings.json create mode 100644 Registration/Data/IRegistrationDataService.cs create mode 100644 Registration/Data/SqlDataService.cs create mode 100644 Registration/Data/SqlService.cs create mode 100644 Registration/Functions/RegistrationFunctions.cs create mode 100644 Registration/Mock/MockDataService.cs create mode 100644 Registration/Program.cs create mode 100644 Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/appInsights1.arm.json create mode 100644 Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/profile.arm.json create mode 100644 Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/storage1.arm.json create mode 100644 Registration/Properties/launchSettings.json create mode 100644 Registration/Properties/serviceDependencies.json create mode 100644 Registration/Properties/serviceDependencies.local.json create mode 100644 Registration/Properties/serviceDependencies.usim-adp-registration - Zip Deploy.json create mode 100644 Registration/README.md create mode 100644 Registration/Registration.csproj create mode 100644 Registration/host.json create mode 100644 Registration/local.settings.json create mode 100644 TikTokApi/Configuration/TikTokConfig.cs create mode 100644 TikTokApi/Controllers/InternalController.cs create mode 100644 TikTokApi/GATEWAY_INTEGRATION.md create mode 100644 TikTokApi/Models/OperationPayloads.cs create mode 100644 TikTokApi/Models/ProviderModels.cs create mode 100644 TikTokApi/Program.cs create mode 100644 TikTokApi/Properties/launchSettings.json create mode 100644 TikTokApi/README.md create mode 100644 TikTokApi/Security/InternalAuthFilter.cs create mode 100644 TikTokApi/Services/TikTokApiClient.cs create mode 100644 TikTokApi/Services/TikTokMarketingService.cs create mode 100644 TikTokApi/TikTokApi.csproj create mode 100644 TikTokApi/TikTokApi.http create mode 100644 TikTokApi/appsettings.Development.json create mode 100644 TikTokApi/appsettings.json diff --git a/AdPlatformServers.sln b/AdPlatformServers.sln index c4eb886..95dcd95 100644 --- a/AdPlatformServers.sln +++ b/AdPlatformServers.sln @@ -9,6 +9,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoogleApi", "GoogleApi\Goog EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Management", "Management\Management.csproj", "{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Creative", "Creative\Creative.csproj", "{6F7D9A25-A555-4355-8417-255908767870}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Registration", "Registration\Registration.csproj", "{F2855523-594F-4C86-A2E8-2CAF6E6FA175}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaApi", "MetaApi\MetaApi.csproj", "{92B2C2C6-7D47-48AD-A8BB-7EE02141B950}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TikTokApi", "TikTokApi\TikTokApi.csproj", "{90100339-E52D-4E6B-9F14-B034192508E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntelligenceAPI", "IntelligenceAPI\IntelligenceAPI.csproj", "{1971AA11-806A-4482-BFA5-8C9479E6EDF3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +37,26 @@ Global {B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Debug|Any CPU.Build.0 = Debug|Any CPU {B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Release|Any CPU.ActiveCfg = Release|Any CPU {B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Release|Any CPU.Build.0 = Release|Any CPU + {6F7D9A25-A555-4355-8417-255908767870}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F7D9A25-A555-4355-8417-255908767870}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F7D9A25-A555-4355-8417-255908767870}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F7D9A25-A555-4355-8417-255908767870}.Release|Any CPU.Build.0 = Release|Any CPU + {F2855523-594F-4C86-A2E8-2CAF6E6FA175}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2855523-594F-4C86-A2E8-2CAF6E6FA175}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2855523-594F-4C86-A2E8-2CAF6E6FA175}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2855523-594F-4C86-A2E8-2CAF6E6FA175}.Release|Any CPU.Build.0 = Release|Any CPU + {92B2C2C6-7D47-48AD-A8BB-7EE02141B950}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92B2C2C6-7D47-48AD-A8BB-7EE02141B950}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92B2C2C6-7D47-48AD-A8BB-7EE02141B950}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92B2C2C6-7D47-48AD-A8BB-7EE02141B950}.Release|Any CPU.Build.0 = Release|Any CPU + {90100339-E52D-4E6B-9F14-B034192508E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90100339-E52D-4E6B-9F14-B034192508E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90100339-E52D-4E6B-9F14-B034192508E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90100339-E52D-4E6B-9F14-B034192508E8}.Release|Any CPU.Build.0 = Release|Any CPU + {1971AA11-806A-4482-BFA5-8C9479E6EDF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1971AA11-806A-4482-BFA5-8C9479E6EDF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1971AA11-806A-4482-BFA5-8C9479E6EDF3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1971AA11-806A-4482-BFA5-8C9479E6EDF3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Creative/Configuration/CreativeConfig.cs b/Creative/Configuration/CreativeConfig.cs new file mode 100644 index 0000000..18a199e --- /dev/null +++ b/Creative/Configuration/CreativeConfig.cs @@ -0,0 +1,74 @@ +namespace Creative.Configuration; + +/// +/// Configuration for the Creative service. +/// Bound from appsettings.json section "Creative". +/// Override via environment variables: Creative__OpenAiApiKey, etc. +/// +public class CreativeConfig +{ + public const string SectionName = "Creative"; + + /// + /// When false, returns emulated/mock creative assets. + /// When true, calls OpenAI and performs real URL scraping. + /// + public bool EnableRealApi { get; set; } = false; + + /// + /// OpenAI API key for copy generation. + /// + public string? OpenAiApiKey { get; set; } + + /// + /// OpenAI model to use. Default: gpt-4o-mini. + /// + public string OpenAiModel { get; set; } = "gpt-4o-mini"; + + /// + /// Max tokens for OpenAI responses. + /// + public int OpenAiMaxTokens { get; set; } = 1000; + + /// + /// Timeout in seconds for URL scraping. + /// + public int ScrapeTimeoutSeconds { get; set; } = 15; + + /// + /// Timeout in seconds for OpenAI API calls. + /// + public int OpenAiTimeoutSeconds { get; set; } = 30; + + // ── Image Provider ────────────────────────────────────── + + /// + /// Image provider: "emulated" | "unsplash" | "dalle". + /// Default: emulated (placeholder images). + /// + public string ImageProvider { get; set; } = "emulated"; + + /// + /// Unsplash Access Key (optional - basic search works without it, + /// but rate limits are generous with a free key from unsplash.com/developers). + /// + public string? UnsplashAccessKey { get; set; } + + /// + /// Number of images to return per draft. Default: 3 + /// (landscape, square, portrait for responsive display ads). + /// + public int ImageCount { get; set; } = 3; + + /// + /// DALL-E model to use when ImageProvider=dalle. + /// Default: dall-e-3. + /// + public string DalleModel { get; set; } = "dall-e-3"; + + /// + /// DALL-E image size. Default: 1024x1024. + /// Options: 1024x1024, 1792x1024, 1024x1792. + /// + public string DalleSize { get; set; } = "1024x1024"; +} diff --git a/Creative/Controllers/InternalController.cs b/Creative/Controllers/InternalController.cs new file mode 100644 index 0000000..8bf1444 --- /dev/null +++ b/Creative/Controllers/InternalController.cs @@ -0,0 +1,48 @@ +using Creative.Models; +using Creative.Security; +using Creative.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Creative.Controllers; + +/// +/// Internal endpoint called by Gateway. +/// Single dispatch point: POST /internal/execute +/// +[ApiController] +[Route("internal")] +public class InternalController : ControllerBase +{ + private readonly CreativeService _service; + private readonly ILogger _logger; + + public InternalController(CreativeService service, ILogger logger) + { + _service = service; + _logger = logger; + } + + /// + /// Execute a creative operation. + /// Called by Gateway with X-Internal-Key header. + /// + [HttpPost("execute")] + [ServiceFilter(typeof(InternalAuthFilter))] + public async Task Execute( + [FromBody] CreativeRequest request, + CancellationToken ct) + { + var requestId = Request.Headers["X-Request-Id"].FirstOrDefault() + ?? request.RequestId + ?? Guid.NewGuid().ToString("N"); + + request.RequestId = requestId; + + _logger.LogInformation("[Internal] {Operation} | RequestId={RequestId}", + request.Operation, requestId); + + var result = await _service.ExecuteAsync(request, ct); + + return result.Ok ? Ok(result) : BadRequest(result); + } +} diff --git a/Creative/Creative.csproj b/Creative/Creative.csproj new file mode 100644 index 0000000..88619cc --- /dev/null +++ b/Creative/Creative.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + True + mcr.microsoft.com/dotnet/aspnet:8.0 + creative + + + + + + + + + + + + diff --git a/Creative/Creative.zip b/Creative/Creative.zip new file mode 100644 index 0000000000000000000000000000000000000000..d4b2a7d0f93d55f378f0e7155f6c58bf0ea5f2cd GIT binary patch literal 20885 zcmbq*1CZ@cvS!=1ZR@se+ugTq+qQYzwr$(C?Y?ch_x|U-*`3|DGqW4-MAR?NsS}aE zipu&jv$DP{c`0BJ6o9`z4??2afBW*E4@dw403~BXX9sg9cY0?xXaGQvzyA5#Lq!=D z0Nffq#Qg6~{%>L+03iO07`%TJBVprY>|mpBCE)C2CTec==LemkX#OcycVsLoFW7_15*63HXmyxMVfu}&EL;pyrvVMXkA#!Hfpo5O5s zlR-_FY&Rs7AUV8_UttyEK?M0(L4*T)C-?cqmTgAz0}BR3O2L>$I2D0u3i`p8VKV%8 z^etSd5WA{-I@5Y)@-nWJ+aL?oi#rB{5Rtlrn?JTXVPqUX)b8Vt+%P6{nxmB2Yp`Y$ zZ24mye;s?!8L(EJJU{jMbOj?3AGrs6bY97Pq})La`jP_3o|(T@l3;NJ7~p=S z!kLEB#S7LU_+284tk{_H|+Ei(M-*Ox(a3P<;Mp)+ zddas2dEZIV+X%zDAsvu6RAyx$OIwcMPdM}FlZZeG-tL1E<%+^hP9#eKFOk^M=8EO= z<1sa5*m8U&vzh2L99&ZhsAg`3#*NI7^c!zr;*&Jslkj4S%8 zwSgA}?@bV{zNBwOM2j1Tc$Ve{Q!{5$xdZ+&k!U3QOkI6Dd=2v{H3UBZlU3q_Ui7|XM^x>|CAuN->byaW7ds}E z6bgR(HBNjT*Jb@f%mjyO>axy`bGK^c%BA82zlK-TX61sOw-YPjau;7-3LL@)wdW=q z2mrtw3;^Iiknk5${_lWta4|PDcKmMuir{a6`cE-Jws!7f#x}+d`cAeEO2&Wd{eOX! z#;3!AIMR1c$>~xtm4iOY>P7{pAcV1?pWW-O!2q6!LM2hOHEq4y?NO49UOPh63{YZLwb(0^OSL}gv4-={c)dKyY7M)= z274w-5MJlX?Az3$dJpep;Q%RK)xKNpI_Z5lOzp!)Wb-Sdy{IwnDD?*4(LrYP@fVjAG&U^U9f_g)i`h)u&VAygnWB{CbNu}Reeeqz;QnuPZ^+TIDt z3*O0oj*m-T;RX4icmu(wyioyrw;R{dG{1?x+|7r{`I!sP)uwAT(~Ulz%lOV#$wY{J zl;}|6Y0Bve=@0rN%uRa zlGV{=6umIPK<;DdHl0z=$Ue6gR)k>eN%0!Ye|;>UAO@ z{>EMAErY>{L->vs2RBNQE@VXLdDx?vBuXyES`hs}&KE3Pzm*u?{Zo<}d0mjnFk;rEXXw zg0t-xUK%!+Y4nd)FbhEb&H>FJP$JM|PdlQ_&=+nZxPhg5+RWcZN@+4v20X=ep-`dz zKDeS+;_4me92?f2j0)|1Hjeh@r!$wtln3IT??pPQ>hz(d>NVX+S;^x!e5FxSXOfE~ ziPrsHB0GX?UucORerW;2V#N>$?NdW3CWIT6fO!CVnQxnls&Rue1cPZJN}~m-h?Elf zdosJuUcK-<8gM-`+DmZa_&>D6l@#%zXMI%XGt6k}o%$e2I|uw@(=wnKFO0IP6r zm{1dZW3b2K`!!VO@bp8jLKW=OADQ?nzYnqFXF#)$0RED$H}yc}B09<@#E5m`Dgy4G!Tr+C#); zFmH*bP$NrTYjt-j3&#d8eTMMJY*qk0x3NfSP<-|d5Y%sP|`8d z+)5Ev&S&f?&H6=Uy)T7AS*ENNie8rL1NT8E-6vOW%WWGH*HMkNJguhqvA7m zrGu9fHo{6nEF>*#PpQKR=@8K1_PV*)n?O|`D5`Kqb#6gIK|r7^AxcX z0-ntcXxnzDeQ*AwzQ8;#;$iq4XCYO&OxjaxuM0~mYF^)bJ$)|ejB$uj#zh_F}KVvWR5w9 zI&?{{>bLOENsm&s8!)RoE*3=?(iYP#-9+Wb7x#9>v2oKNA0!z2h{~@W&o1w^?@3=D zk0-t^Wir2NOB=585!|d8yeeS|J?huMOK37mDP!7uHm{pNH)5{?cT+U`t^F3*U+jd= zF5RRu-l3*3nLI`!Rb4LKY>~r5YtSCUM)vYW&Oa-9sT8621=YM5kb;%1i7I6;Ec0?_ zog6Tkzz#dlOY(jMXOlARuQ*K|oh}~V|GJlR$_i9M&4xtjQ_VPR%9hR_j=1j>I?0Q!otCGwGekOwt2H4 zg(Y6P52`8J@|=Vyx65`YT8_lWok}Vx_em6P7Rj6AH|P~-=osK9;U9{Jqp^H6Yp-O< z3hT&SwsMSd4dRFEAYB3#gP=HeJ-{v}Tw~$Jxk=!m~hUi`kB;7cjGsI&!)suLm9c1exGSM?f z$u&3meLOKr^RV*Ec&yR;O~wgrI{I7lXDJTH=?*7D0bRj3hOl?Ex~9g>6ea713a2%O z$t=c+zM}y59yeW$zA-YL1-7R27M*q$Crf|Go|>>7Jl=!!J^Qr5g}iIu`|p`=kKcXd zFQr+HfLx-dq)39(4(M2CS3s2d)lQPOn9LWRM^{doTd#i$m{~v_%)Fog0NM%u9x(sk zwvO2UTcqn?tnXy*V*LLW=*&sZ zCu@&HsE$lTIzE5JEDiu24<1tJHB~-!nmNQR=EhC>o>7K@+$^j?K#~HQv?6RkT@qtM z^w&)Q(C3Uo%K|?+18<*_%rwG{Atc4GA>pb`>tv?r$O)Ekg-nBCNz_I#tp-{Ucq<=r zm(VeR>>?$UArxRanP9rqw`~ns@6^~5auQ_Dqos89=xX%^ygkeMR*JcS3e~LRm6f2Y z<;7JY^s-EV&{1c}BdjF)lR+gVIm;D_lN74L$FkDo3O$zEQ&q?+BRG=1N)onVQQE`Q zpVQ`qdee+Gnao@Rg_fcm6|8|AhLnt)YEKRVR-z+ERhQM4IA?eJYmdXkhPPyJmLB&zyb$q3q5E-_WXe#8~mXdH`&Dw8$LeJ z=_8#sQ(V$h0Z#ypE7$rpI2$`+9mK~I$I71 zZti#txnra7EEu%EL;ZqWT#^>zNuIlEL|be%jpGAMNP9TmaFYU>O^$wVR?L!SE0&8E zRdn%`EZlh?S1OwiDMrd5-82gwR3pSbLmocJTf&E<_pCS6iF?HDIlW88M=<7rF{WMQEt?l{T04c7X5K9BsMv0hNsJL)NFyMxAYuU>U||)9YB^yK;t; zrH}FKvYD~;Q{2N;*6rn&XA?~0P_PUfxuKx#)d~6yEb$54M~P2lCbYk?M5(#*6YMio zMJ)PAd67Adn6_et>6)rROa!J1xq&t(ehliSH^EZ8DHCn*kw-3<`oN7PWQ{nk34Uj* zf;#N;CUYdK(|{d5TmHAQm|w0_pKn?Qg6K)t0)k&D7QK8+*dFRE<_Tt_W+&4QIk$`> zw;zR|4q&(qw!6^uh*ifL%)snIoO|yqJlcRqedYQumEgLo^pX_GI6N4rG`WgGX#Fvb za#K(thhP?je%9Y8=IceeA&}(pW~tr;Z~-Sn`x;yk>sg7=H-I4dY^wV~g55z9Q%H8f zrfM9do?^-?Eb&hXK&_zK9_Sv%xyt5ijavF!leMbHhL)i}2&|vnpQP_$QdQo;~kEUp*;KpPqFlZ zZ|vN}NjlBlgi~Kvo47kqD6oBo%>@J*G~ne1%~#-6Q%!^#8o6@dWjDm&(~lVF=K;qZ zV!)Cvl0u-;q0w$a?LMRuqiRFc(HDf9ac+k00)|OS4K(m0`k``x(oF(k``w*`%Yz(4 za0Rk3Ngq6pAU8{Ne$Ub-wHLl@eTylN4)!Ql{g=&AQCYi$`ooSQYU4w!)&2)i`e z6fSX{!S~6Y7Gltu88a1rbmR`DndRs3X7MlpzN?0OII#NrK?lZRQ#qYs&|43fnvkFw~L$s6+)8exJ zUY72eq+Vz;!9R{k@-wThnoDmwxf4S#lCpHcn6g&U=PlR$!UR`?gUpK^ZN=_2k;dHc z(pYvjIB=Ko|5d27p?j!#11upT-?f>gcWEGp+VDAq^rBwZMY`Z2fI2kyL6D!8R8>${ z{9-q82H!-gpCf2;RL}LOp8_@xO{dhCg#l$zd4823{J6ugiIVq2Z(p_x%{!lwS9T0S zR{k_3cMO3u6gyw@C{}{(5s~GHzjTsReQVT2Go!{Ol>Yj<$n0HZ9Qp#GF;~eko^;`B zAM(WaguG9R19IMaYP&{<$KbNsnY)VPD}*K>Z{DEIyeT>pPck;VyV#_qe=+86g=d7< zfM`F};fZulNcU-RR?*X6-tvqlaiN;kBGCftlO)9DJUz+qzUp;(=dW~GMQ=u85;y?B z?7#H>g#YOMC9L&LjsHIrU3!0FfFbM+n+^6S9^F312vsl~4s)(8%od4ViFsCLbL_>l zhdc@(k$h`wqcI^#d+i$k*U+DOd}iN9-9|HeBKaiaF7r<1p@V2+jwfyL({32Cqk7Xy zoc1$kGH+hg72OWLM-r7g_)Ou3TzYnM74W-kl$$b#u%JqZ56Q` z)%&(wm9!xpn+wNgvoKKZ-E?qhfL1)O2W2c^nS_BdpF^KmDvHN=T`@^nf zignl{Bzimb$A2mb$3oRUO#{sQA+iUzo357*NUbleXbWXU4E zfa(=Rv_1J}%rVzZINQ}@RYrqmR z<%aUn(MGO|eYcyfXmHKhcl+|P+0*Fxc6$2z>;2+ZE=YkdP1+1+mVXNWBi@T^7C|Zum@U27K4ifiXnwTxy|w!H6aQ zA~>nq+W|3LzgDhJ)4?F?cNpeNCPkvJ0-0})_#M~KAmm8zGX-WjGw^a>ue_zrQ*d<6AP&Y6E8@cjIA`I5^_;)0RrngmRR5WQ?xb3LCBIo;}@1Pre| z(G6FX+&U*OGOVj@9v^M+vo-+!gep0CX(IDMe^_`BWERS=dku*8$x!I88*_8tH6qXV z!Hm3$*}H{m#qqj0T=8-xFrtqV(0;^j-Hs0+bUZ1<@+1n>uKX-H9aXE;3-6%rv?9S8Lv$ji+e&9&AIE9t)KwqhJwq9Dtxi$)w~Hwcpim0)_;ao`MgRKVwqz8@K0 zKPisf&65P{!4=#^%Qsn5@uZ#Ci`b6ry+D2;7ajxJ)L)8=jb9E`(70klhO|8}_#>VHh68}7%3XxPfiGM(}2;X;`afRb3k8>M!N1??uL ze*_aWjY*KlCeaWg^+wdA))w@h=L%5}DuppU=GG|ofeCbiNsJqRJGI1$8K#5*{(PMC z`DI{1p{`4jpAvUb*}o#^KQbYm=%OmqrCEeJF|9`ZD^mH`rzzaGJBO20%yp&6yHz4B zdiBdiMpmWR-L^eQ`H`zav7u2gjsLLg%mK7r%VHrab*ZyAzI&bLx!R1Qh(o%b9TgLX z-L7Pd@jl%a(fwZWVnbwG#oY)m=oy9SqZ<*%MFJEMbr8gC*6Y?mdAt5pXdUK|FH{Uxubw)16f$ z@mN056LS&J9_x*Wv=1oVfd)a5LY6>_jKYh8kc{vN6h<9iXw_IpntP{2-VaF2DBH*g zBzQblz5i2Sfco}vhgNKz=|F7! z*fJm507SpO4F*y1sY&An2jg}iBcz(nj37w5-NDt#`1^z(KpUM44=eMMHi~{CRB4>9 zxOa2gW9N}5 z>e^_3I14Ry3M{8lgmX=YvsX0|83^<=5s5X`U8<{mwHH$80{;6?BtJr$s%NI6;b~U< zi@~6}HRSo3c^kOzLn7z6;<$+u`5#W(ffMJB=DzcsK{BfVGCaeSqRdtt_@q9p0sy8j z%K{{@69A-c;t!%oe($^z^9NzFzhngA09do8vy#)-Eb1kG(7~iJ+WJTyC~&&0$Fas` zajhCDg?VSB7E_jq1+oy)t}PN&l{Z;Fy{CuU$>ov^-IQd_xH!vpp8Wzl@>+{R@bnb* z1H`YIAi9YkX2}SFeqx+Q6|fN+LNgB6E3(ogbUEFPc4)M=+U6P6(t}N!xMG$%3W$Y~ zL1V#;Y!x=re1vc?R~^~0V8|#iLK6!|--Mb#NXTG<;=0Ml8h`hF3O9ybX|&NTj3kDh zW5!89$sRmQ9FYMzCjA9EtR7_zP)N~8Bi~8U1wrwwLBaV2Aj`7m3*EPIEpeUPW5;N2 zwYdrI;mJ0AYAxEU*_1TVo8ZY1g5zI#1(||MUPt47Uw0svd4Qb*w>J;^G0k zR!G~bG~DYNL05Rj%q5J+2bbh(S>+i2ypI%v1v{gg|U8?pl_R-R-N!QIa z++C5p+#HBGm5G#0dH?_-Fj|@u(J=Rw#-0>3m;;tt(_4|>Z?O?$i$THxAkzLlzZXlV zo2NQZQ|Rcl04e(ic^gSxRRqt-Uf@WfT$C6!t5Ez%}hTVj6#7m3u2GDpu_D{AD+$2=wf#?)i-h*FVLj}qNM?zkxLdCNl zIvMs@!)ojUGzg5o$$6)lQxj_-mHa0eMI3&^cU!%&_?ERdsVjcLy1(a6`8#Wjh0=4@ zKeERx%~8_o=MnuuS_Q&hw#ne}m;EB+*Wc_p!=sK~n!4!7uo6aIAFXTy#f@0!rgvb) zefES)Z3a8fQkm>V+t!q`_>0z@xzm8S((9SPQvswLyE7AmmoF0h@>XQD&h;;W_Gwt^ zjXNG`HAl{=WPKpuS)@y&+(j&X7b+`2j9t)$XIz1Hq7OFwe|0`nNv?iGh%YdlxVR z9U1#b1hg%Vg;?i9>i7&z`%3aqliNVl$o63;|wvQb#AQ`4nBzI zF>2y4t}l2S&nMg@9I49z!Eooz>={%9n}3Ax{H{Vf#%FsBH?Qa*hiLDUnKp;kSv)9`-EXB| znGdL@6!cREh`2K%g2^$23ydFAqPTY6NmbkXa~qTzEAh$!=!vF7?YCWzQ%#jVpIk8- z(nhcoz!ddt?3R7zRn4b&T73{Iqk{M)%UH9`E-nkNGtLl&ndW>{y(-XjKh6X>H3}XK z7T%uvPXI(EQ69u$+Ai66)Z7SaI&A1^mRmL(-ShD&wP=r>r##$eToPqJhq;8FB_g)! z+VOL2vz%$4>-*v1E~1mXewOv!<*IyBhP;pt=i}9+B!`*a%d|{S%xR=oSvW6@pF4L8 znf^rVu-EOP#Hi8-&9U8$W3wN2y_{bW-2UnL;Lu?`pNnolm@QgWFEU3k>C5krUM<0w zS*j}}vZz!q08ncHaTfG~2zUEytfB4HbWlQ)J6UiE`3-?P+;vO@wZnX4>w+WQxU%bA z{;F?dJ@%vi*Kf$yUujFEJx=dR;%&KiT-3}iGVzGQ@(=Yv+}!i^K%*ky>tI?XeS~(b z{X1QHwQa)G;5ScAwD}`H6{#pim{vAn$LzC1&4sb4QGNxB=C0=*C0zmBZ7#YuewGU1 zeL{c#IZ)`q*KZ9TMVHNt=t2(?a$+U+iA7fbmbAGl$E_ofU4{Sse+g9euH~h zf1inV<+wW2F7jGwBPm-xA4onoHr+q$v7S$`Wy9^+RO)eOcD4dos&! z|Fw%uxN%Duso3ImFKWV5duYjLH_mtv$LoN>1h;`@+YpcPGvN(~1erd{7L&hnYC*=Q z8U$h*12_Nc7cxDCQz@4N;c|AcZnv8os&gE_{#2CEv`m)s4}Rqsi^XG`Or0yjWJz^G z5hg%gNEVoeu&BJ4z}TYF((N>X-6pRg_{|iEJzOPRMboH~4#TB!()UA1_m|q2uK@0? zvP~YHzMg)|6IIyiT^V&ydtpw&zzr#H@33DO-At@hJ|HwgHeB+65w5%HITv0bcXD10 zqT;(BiwF5sa_Z|!p8T1D$BvW5hFOVG!BqEvE5B2F@(0i$r8sodT!V;h1sJv8vVNX9 z-1OsUux2^|us%EY*I5z>1}vmcnb6rFmbNE7Y>HjZqjnJ)qlt(C(?bcw^QZvQv?ObN zga5Vhr+%hVj(`UMaOMA3X@9JL-1sXQI_TRO|9?IM2{W$JeQ!FP-*b)FfW1 zKYUJB7oAMmLAo}Nkqkxc=2=_zTQX1@O@@vp461ZyILV}}GsRs6y}t26V6D=EhPueR z^Z{2&I6EVGVB4-*Vr%o5N?(GGpg={!v#ABI1tVLt!c$zIfvRfUi<7O!nesig^?K`Z z(6nTEd892sv_@a6sN}*hg!J2Vp^RWV+ z;I4Gq{Ha$4ZX{E@`xTOn^43@asmq#}#pJ$iXTggErH$jOK*UF>Y9I$JkEY;OLRO>S z_2Eby^NsqcLO6I{rC9L6v9cqlKwxk1js=ImN7U%5hQdjL;z(I1DuH7naB|Uy68H;% zBf;-VVKDR<&X{VRVVhM;^Ta4OUcnohbrUJk9q8x_(hDF`Z)tmQ&$WF{JR1Hb#rt?F zlBG~@(H=Srg}aq@Dms$>`1ICD$%-)Yr;Lh8ov}JIhj)RmIdMa|_z^{HAM--q^w}@Z zyl8I81$GF_hvv7+-Ml^M?t%S6DvMNjn2Y*?2Obn zm^+-+=;64DK^^`%?Rgs<*w8B@7WP9^W6DQ}F^^F(P1IYkwcth|Q2f^_?JVfU+$puy zceV7{z2DzTd>g*&$TtsQ>%k>^*Tm4FJFKehcW7eW`2eNHi6?>d8+LCOp$jK8z}mTf zkORNby$iP~44>C8I8Vi(5aNl()GspW$)uBneZ2?pG=A{}VktZoynEr*t>u?xdgd0Kq&Yw1hgYdj9$Qn=V%x& zQrZTho6WlMPy~`tGAew}31ZzMP5r)QEnN<;`M}Z& zQo^k>B0jF4LIElM&z^MMj1f2mYBK8zVe_UP!F^sTh-tz?#+agMnPi62`6@i0 zB0wuZu=Km@qBJ~2b2YtzZz~ zZuks@C2i2DO3B;%paRA-l%lyHs>`{iiEq>V@^S}ayzjgzdRmdpn`L03D)F50ur1=N zi+eB#wFuJ{MeX~>{srHIAlp_iy&=Gs>G8>)6*7B`nv~@4$+-P>FVLJAQW16{%eD-w zL|Z=+9mOw62*6JSO6MpIC_eEzltMQoRgguzV;cWKVwQc9N>vcdR+a6e4`SAMcQ9O} z%EEI8O>MW(-LJwKW7Zr4X2;38dq!^4_YvivONgDUxuuQFa8#01CmzpPf``S!&25Jz zkx!u2t{dLl8+&H_f@?Vncxg1%jZJ5&{A{s{6PP|hi8M-ouD$j`>C4%=-L?k15K#VE z#~_?#BzUjj3|V{HIs;h(W+v7*&pd7WRRH%u;*nYLW5D^bMRe8^{Ygj7Tq{jBEmZ{-$fGTJ7o&k4Rv{%MdbE=Q zr>lFhMo8DBzn8SNk8>msh$Om>0qCn}MH;}o94X~?y34mw;E={($2Jpn3}8((9I{rR zWG+8*cK%Io&R9%FvXDnxO}vz&YBx`De9=n<<#k?CfngE1wMTxrIf~$1M}YzHN|=)( z9Oo=xA}VoPV;ts!*{~HEWx`FJc~F?XgUuCN@Woc_m(GtI>rc?X{5?KmVMo}XME~(0 zhWS5dh~xb6_xg5rj>b+-<~F8|bi&3i##Xj=*2XqYbQX@bHvf(vUU94_FMl^CCpR-G zrB<(43g$ov#9L-u#$kt;n<{0YX<#l3DGxC!O(ml???3ipLWX{Xeo~Tj%7KRdQk2Yo zf?gExE0`SZ$Uh3SqT&c>Pm}J?V8I~_79)I>{?K^s|FnNTc!d9B@c(YW$$t(djN|)c z2N+;Nu012D#Do>ZMuN4L2o>r84TLgfekX@5Rwp#V6nAq;wAdJG)4bl>zPEH{f%vQ2 zsYsL$TGu+C!O_$TITOT8)di_)gbIt2{>G{_LP|08rgWSNk@8GRJw~)1$Le~4WqI8T ztahz?rX^B{=&5ZrHH=RA*(#B3YNUW%3jXorCZ`l_^D|T|(iP+yN*6(SUOZGv9?aEb zP$}@2_(-gf>`OxA9+Sck)=YbKsRUD~)yh}oG3*Rcg+}^n2Uyr@M-X{_g-Z0!Ocp7Vw`u(z|Y7(s!soku868ZI2~hP#Ta_#pN1v zvS((wNDe{L;oW_cQ^YV{4m>}_nJ1fn9rreQ4-uZJ5ZHG-SE|We!cMzK2JFSq8C1Z; zQ2}t`gcNMpxPVB~hG^4t z&`To>^i;W|X*Yo9NT|eck?9>cBAYA`?-m-*=I0x65SLMAT`+db_}+RT<}i5BDzC>Y z8wHRwj@p42d%ap9Pf4_DDM`U(jUyPgB#2RUoU82wYV-TiAG;5qG^lS&j~#CP;w}L+ zN0v7W#I%Yj7y`L-1g*Fd!;KvWwtysx%2h49(1?j>e6npQJxfD_3Moc(sLQu(#B5Dam-XXbIm! zgafcU*BRLV_#n>fo13nPhrnnYwwO`#NTNST-8da+R^gr@WnwmN;S5%KdJt2unJ`A7 z&L2l;y)X($W!jq;^xW!wB>-MJtE;IsAhtW);tWJ6$i6&Lgp=l;**sSfwk2c0!eWhB z$hfDhM5 zO(ou)oxf(d_nzCNo5L*3*lVtmB|SZ~8731Xa$gh1CZob4*Or_h6P;N@ecDW@forQ- zmAm?S+-605LY%r*c{(v!>+O3SZLqRj_te4%IBL9I#vYrD3tfOVE#XjbevT7y1D z3dviJ5s+Asgy;GJ6B92x+Hw!g3X&mZTF_U6z@d)RXf)3XA6T$IXrH3RnYt(L(x5`= zY5?cPTC&-#mo)Oin<`DS3*{=IOCMiOT5f4Ont(~tv#L02SO*NfTJrd@f78FB!tqEakZLgO^g4QMozMFCVGq8PE8bsN-+e0RB`j6ROGQE- z7k1nvM}5^{fH;`H+mAo~_P^B5!2MEG+Y;6*H%fpQ|lpzb2*mg61}1FwB8%n4|#EWXRZE`Zr@aRS=`1l6w;d z^Bo1FtxqD0#3Q+l%#!n_xR05H426T@Ai+wR^cxdOaf~J*Sfl2yR4cZ&ev*~G<>!RS zv0DRjW|Oa7t4>OAMuCFkFk%Ysir4)JH zy9X3PMAA0*=1N7^`!PkQ4U|&<=mFOm7iKuMGUZ)N`$fbNX$YNHtE5+`8xUfYoa|7K zVN)~H>o9=SpESW;t3MY~H^RZeiGo7K-FI}qcT zB~2OI-wCa%f0&LZ=-qHEu&l0o4q<4Ar5K6JK8z@3*aCep*yQS@1x30%KtVAr)ShC& zVtJN){m9`-lImGV`xJo^7c!09gt)Fk6M)qYpdZWTo}I#w(WPTcIS?IJsw6h{VLnea zuA#F)m~FUwVi0d7PonAP{!UNPv<+$9)lEZf*K9i8Ta&|g>aR354521!dWcu-_Rg1M z{B~$30oVdPHIJTtgPUGf5Ba)40z-r}o}rJa+2nx&VW|1(`No>sikJ_HhdT6_=lOEoO$Bm*c?b>%Qta&?Nh z-DqncP@_F?j@`0aq8aUY*^uE*BU({rkr>&g7fpw+ni2v_X}?5r=s z_7PjJp{42mw8w-cB@f;DvIoto+p|aI3&z&`1M3U0sy$4Xmj7cW`fu!>p%Vo&j6Z-@ zfc#g0hWQT=Iyu-{Ss6S0ck1E(;tBl$?*EkZ_Zrjx)%R}z{a1~tx3Y}wJ_AD6iCUC{ zBniZ`Pei>`q>C2St~_MkxO0es{zc_wujtP& K(DFypTdz)cX4?cs=36-6;K5p2 zlUOFPYWjk^Rc;YYDwA?s9E%BxkSHl_5CQL?Yr+i zY%Hu;eWiDc%wnCjrhdG&+QW)*W9w!eYUi$5Z?|2;mHJJ~r^Vw3;EW@HR(g?vq7jCb zDGA4AqIxs`*xAuj8<2#HSuV7U?rpj7(U-cfVUaj!18t##9h)%`XAhl{TF&1l&F#{| zQn8%a?#5=8Pc5J?`tWA4Sn}fl@;o5}dq)L{j2WM*tu4{L3F1!fMlO6^JukzQd=M@y zchsFZAWgdfz(Q7K73m0BtSzercmgjS@znie-nsaSA{Kouj?#Mg9GotkG_?@0l((e? zk2=F`a>4IC;b;x{>fAKJgqh>VxR0I02TF;}=ec8uVH;?+?*3-6k;|v30p(Kh8Eg27 z&nff^#yijI03U?k3jUrmzH5xx+dg5PW9es-5m%=sG-I&9e0=74JeSB}Azk+3j*l1|OKY_9p=)b}S_@5OmV{2q= z_1}R7#{UxXclY)mGXD)42>!UYDYY%fb#?^b8Qr=8!!m*1SmIUAdmtUON}w zx2h=h8Wo&m+3eKD_qZx&wf&oYXV3{d`~5c9{akg&wf2pz62(=;GHym4h&psVcctPY z`52g1*lxzI&HBkhWfUweWk|+yS9W2nad%k=vN8Qq;=*MbB(FZ!guq&_(psxG3b(P1a{Zd^pF?y* zOL|khbTmJ?-={5dX8L4X(tgI^8mMs|)|d2`Ot@g&`V-O`^1ay0X%k zv)HK?nWVzqrSQmiRd)%=Gn%IOvK{O#+fwP_)Y46@l7nGe#Ci2AIEqcFuK`BJ?|xH58RD}OF`Y)i>n12TMP(tI`k(3@N8#;cdnZNGpGtA?Xle-w#~ z{uOwgtVFFLri%-OOTRn66Ht5Q(Mh3lqltwyu`6!uuoXs1J!@j0IDlN46`IcqlNw?Y z{cqz2(J=LN#%`pMPcKVES@v~v$Ol(=dD|;;?VFYHg>LY1`ka;0S49JQWMCfCfW-<_!i2lOxFwzxvYygx1z67K-Y1&@~dKIJXwG~XXRF7ROv>+631gIlW1K68io7*HT&Pf0tFj>YhxiebErgkz%jad|%$j8x ziQ8#N&HUQINx?P`U}AdfKPUdU)1$M~B3Il87Md$DEZVPH8=Vl8sarDi;nYPs=bfWS zDIp2{if20CQ4I!K!Gzc@6iv;Bg1?A?>%35&A~@COm7gPQP+YbFx`R^b%QjXuMz_8V zZD2QE%nNy5k$mt*ZPRyKsMmkB)=4*jz^;q9Se3(@Pft`*IU`Uvvyfkj46TR%#Hx0O zqg1{p(|Ms^>@0?$EbhTtpVk&hC~uVenssv36o z69wI1b-mNaPscU@f= z_}!|#?Bn){{FlX+(yXcj|6{E`QU8_2hW=x0|D-qm98@})|F??!|GF*X9|T$HJKGqV zDg8Y&`tOp9e?>CZv3$0H3gre0mO>rX57%OC^YV-Ow$oFOnYGIsaonweJT5`muUGM^V zXOw!P~*){^r9oodVZn+f_f)*1e-`(mrz0u}!{pO#}PvilC)%BIjK$5%O zXkYKLNB~?syVKn zm?>i4koEgz<=6RKOYYtCNYqN-bS!_voSCTyuly4G5|#3D>VsJZd`1V=_px1;$lb?# z+Cfg?@hY!^>$e-eITi;T^5B}RbM%ys=+rfr!WdgU%@%w-{({N1`GpX##@UNTr&u0M`>zjEnrUz?XF((kkt2K`{2(xl&b!IVp}ZJ%u1 zf6ggpt1ebvw_K_9RHc4(!sW|TH1-Bes5M)2Lg)LOwLL0%D$fEK0>dVmCs{?7ty+@2 z@9Vm>ZsxN0nF7N5Hd!y1>xgNb$GP&dMF%htZhim!EnfVNoNSHCiQV z%%H;Vkd;hJ1+aN)4?o8Qv@KHuEk{|9m+;c-GG?i*MdP?LTY& z>{)Yq_{CzxJk^wvVmR4f_eEYwbS<5tpYH$Cc_W&!PIC%!Lam1c*gTof&y++a~ zwlwh}IU3eQ!n3;=hXu%MniNqiXb?lPfancOIIKaQN|i#frcw>b8p6w^aF~xg+bM%$ zex@D+Y16YfY(SpY6F{*+O&`?;tj!!8rXo*2@uQeJ-++PO1R)MXk;kODPz?1o0%m30 z^H^x+VaqvuNap=_>@@>A692e1nwhZdgFMV8k7VYy#+w*s!csTx@oh9qusNIu$&&w$ zaaLf5!$;jP3U)LTVa`S#0YS|!E6_}Yl~wr1@zAWn=5TfthgaD$;2c~*GYsZt z*}_Jy3y!;>IGSL48)yNj;)m6bFi#*iMU0U`Wm}_yD}D=zZFi&j3}i8=rGs2)TcB7h i=|Pso*xNlo+d*xf0B=@cTZ@4~3<$+J7#K1 +/// Inbound request from Gateway via /internal/execute. +/// +public class CreativeRequest +{ + [JsonPropertyName("operation")] + public string? Operation { get; set; } + + [JsonPropertyName("requestId")] + public string? RequestId { get; set; } + + [JsonPropertyName("payload")] + public Dictionary? Payload { get; set; } + + // Session context forwarded by Gateway + [JsonPropertyName("session")] + public SessionContext? Session { get; set; } +} + +public class SessionContext +{ + [JsonPropertyName("sessionId")] + public string? SessionId { get; set; } + + [JsonPropertyName("clientId")] + public string? ClientId { get; set; } + + [JsonPropertyName("clientName")] + public string? ClientName { get; set; } + + [JsonPropertyName("userId")] + public string? UserId { get; set; } + + [JsonPropertyName("userEmail")] + public string? UserEmail { get; set; } +} + +/// +/// Standard response envelope. +/// +public class CreativeResponse +{ + [JsonPropertyName("ok")] + public bool Ok { get; set; } + + [JsonPropertyName("requestId")] + public string? RequestId { get; set; } + + [JsonPropertyName("data")] + public object? Data { get; set; } + + [JsonPropertyName("error")] + public object? Error { get; set; } + + public static CreativeResponse Success(string requestId, object? data = null) => new() + { + Ok = true, + RequestId = requestId, + Data = data + }; + + public static CreativeResponse Fail(string requestId, string code, string message, object? details = null) => new() + { + Ok = false, + RequestId = requestId, + Error = new { code, message, details } + }; +} + +// ============================================================ +// Domain models - scraped content and generated assets +// ============================================================ + +/// +/// Result of scraping and analyzing a URL. +/// +public class UrlAnalysis +{ + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("metaDescription")] + public string? MetaDescription { get; set; } + + [JsonPropertyName("headings")] + public List Headings { get; set; } = new(); + + [JsonPropertyName("bodySnippet")] + public string? BodySnippet { get; set; } + + [JsonPropertyName("inferredCategory")] + public string? InferredCategory { get; set; } + + [JsonPropertyName("scrapedAt")] + public DateTimeOffset ScrapedAt { get; set; } = DateTimeOffset.UtcNow; +} + +/// +/// A single text asset (headline or description) for Google Ads. +/// +public class TextAsset +{ + [JsonPropertyName("type")] + public string Type { get; set; } = ""; // "headline" or "description" + + [JsonPropertyName("text")] + public string Text { get; set; } = ""; + + [JsonPropertyName("charCount")] + public int CharCount { get; set; } +} + +/// +/// An image asset sourced for the campaign. +/// +public class ImageAsset +{ + [JsonPropertyName("imageId")] + public string ImageId { get; set; } = ""; + + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + /// + /// Where the image came from: "emulated" | "unsplash" | "dalle" + /// + [JsonPropertyName("source")] + public string Source { get; set; } = "emulated"; + + /// + /// Orientation/aspect: "landscape" | "square" | "portrait" + /// + [JsonPropertyName("orientation")] + public string Orientation { get; set; } = "landscape"; + + [JsonPropertyName("width")] + public int Width { get; set; } + + [JsonPropertyName("height")] + public int Height { get; set; } + + [JsonPropertyName("altText")] + public string? AltText { get; set; } + + /// + /// Attribution line (required by Unsplash TOS, informational for others). + /// + [JsonPropertyName("attribution")] + public string? Attribution { get; set; } + + /// + /// Direct download/full-res URL if different from display URL. + /// + [JsonPropertyName("downloadUrl")] + public string? DownloadUrl { get; set; } +} + +/// +/// Complete set of generated assets for a campaign draft. +/// +public class CampaignDraft +{ + [JsonPropertyName("draftId")] + public string DraftId { get; set; } = ""; + + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + [JsonPropertyName("analysis")] + public UrlAnalysis? Analysis { get; set; } + + [JsonPropertyName("headlines")] + public List Headlines { get; set; } = new(); + + [JsonPropertyName("descriptions")] + public List Descriptions { get; set; } = new(); + + [JsonPropertyName("images")] + public List Images { get; set; } = new(); + + /// + /// Copy source: "emulated" | "openai" + /// + [JsonPropertyName("source")] + public string Source { get; set; } = "emulated"; + + /// + /// Image source: "emulated" | "unsplash" | "dalle" + /// + [JsonPropertyName("imageSource")] + public string ImageSource { get; set; } = "emulated"; + + [JsonPropertyName("createdAt")] + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/Creative/Program.cs b/Creative/Program.cs new file mode 100644 index 0000000..f59f81e --- /dev/null +++ b/Creative/Program.cs @@ -0,0 +1,96 @@ +using Creative.Configuration; +using Creative.Security; +using Creative.Services; + +var builder = WebApplication.CreateBuilder(args); + +// -------------------- +// Container-friendly HTTP binding +// -------------------- +var port = Environment.GetEnvironmentVariable("PORT") ?? "8080"; +builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); + +// -------------------- +// Configuration +// -------------------- +builder.Services.Configure( + builder.Configuration.GetSection(CreativeConfig.SectionName)); + +var creativeConfig = builder.Configuration + .GetSection(CreativeConfig.SectionName) + .Get(); + +Console.WriteLine("==========================================="); +Console.WriteLine("[Creative] Starting service..."); +Console.WriteLine($"[Creative] Emulated Mode: {!(creativeConfig?.EnableRealApi ?? false)}"); +Console.WriteLine($"[Creative] OpenAI Key Set: {!string.IsNullOrEmpty(creativeConfig?.OpenAiApiKey)}"); +Console.WriteLine($"[Creative] Image Provider: {creativeConfig?.ImageProvider ?? "emulated"}"); +Console.WriteLine($"[Creative] Unsplash Key Set: {!string.IsNullOrEmpty(creativeConfig?.UnsplashAccessKey)}"); +Console.WriteLine($"[Creative] Internal Key Set: {!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CREATIVE_INTERNAL_KEY"))}"); +Console.WriteLine("==========================================="); + +// -------------------- +// Services +// -------------------- +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new() { Title = "Creative Service", Version = "v1" }); +}); + +// Core services +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +// Auth filter for internal calls from Gateway +builder.Services.AddScoped(); + +// -------------------- +// Build & Configure +// -------------------- +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.UseRouting(); +app.MapControllers(); + +// Root endpoint +app.MapGet("/", () => Results.Ok(new +{ + service = "Creative", + status = "healthy", + timestamp = DateTimeOffset.UtcNow +})); + +// Health check with config status +app.MapGet("/health", (IConfiguration config) => +{ + var settings = config.GetSection(CreativeConfig.SectionName).Get(); + + return Results.Ok(new + { + service = "Creative", + status = "healthy", + timestamp = DateTimeOffset.UtcNow, + config = new + { + realApiEnabled = settings?.EnableRealApi ?? false, + openAiConfigured = !string.IsNullOrEmpty(settings?.OpenAiApiKey), + model = settings?.OpenAiModel ?? "(default)", + imageProvider = settings?.ImageProvider ?? "emulated", + unsplashConfigured = !string.IsNullOrEmpty(settings?.UnsplashAccessKey), + imageCount = settings?.ImageCount ?? 3 + } + }); +}); + +Console.WriteLine("[Creative] Pipeline configured, starting listener..."); +Console.WriteLine($"[Creative] Listening on http://0.0.0.0:{port}"); + +app.Run(); diff --git a/Creative/Properties/launchSettings.json b/Creative/Properties/launchSettings.json new file mode 100644 index 0000000..cfc8e18 --- /dev/null +++ b/Creative/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Creative/Security/InternalAuthFilter.cs b/Creative/Security/InternalAuthFilter.cs new file mode 100644 index 0000000..93e606e --- /dev/null +++ b/Creative/Security/InternalAuthFilter.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Creative.Security; + +/// +/// Validates X-Internal-Key header on internal endpoints. +/// Gateway sends this key when forwarding requests. +/// +public class InternalAuthFilter : IActionFilter +{ + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public InternalAuthFilter(IConfiguration config, ILogger logger) + { + _config = config; + _logger = logger; + } + + public void OnActionExecuting(ActionExecutingContext context) + { + // Get expected key from config or environment + var expectedKey = _config["InternalKey"] + ?? Environment.GetEnvironmentVariable("CREATIVE_INTERNAL_KEY") + ?? ""; + + // If no key configured, allow all (dev mode) + if (string.IsNullOrWhiteSpace(expectedKey)) + { + _logger.LogWarning("[InternalAuth] No internal key configured - allowing all requests"); + return; + } + + // Validate header + var providedKey = context.HttpContext.Request.Headers["X-Internal-Key"].FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(providedKey) || providedKey != expectedKey) + { + _logger.LogWarning("[InternalAuth] Invalid or missing X-Internal-Key"); + context.Result = new UnauthorizedObjectResult(new + { + ok = false, + error = "Unauthorized: invalid internal key" + }); + } + } + + public void OnActionExecuted(ActionExecutedContext context) { } +} diff --git a/Creative/Services/CopyGeneratorService.cs b/Creative/Services/CopyGeneratorService.cs new file mode 100644 index 0000000..414d371 --- /dev/null +++ b/Creative/Services/CopyGeneratorService.cs @@ -0,0 +1,233 @@ +using System.Text; +using System.Text.Json; +using Creative.Configuration; +using Creative.Models; +using Microsoft.Extensions.Options; + +namespace Creative.Services; + +/// +/// Generates Google Ads text assets (headlines + descriptions). +/// Uses OpenAI API when enabled, emulated data otherwise. +/// +/// Google Ads specs: +/// Headlines: max 30 characters, up to 15 per RSA +/// Descriptions: max 90 characters, up to 4 per RSA +/// +public class CopyGeneratorService +{ + private const int MaxHeadlineChars = 30; + private const int MaxDescriptionChars = 90; + + private readonly CreativeConfig _config; + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _logger; + + public CopyGeneratorService( + IOptions config, + IHttpClientFactory httpFactory, + ILogger logger) + { + _config = config.Value; + _httpFactory = httpFactory; + _logger = logger; + } + + /// + /// Generate text assets from analyzed URL content. + /// Returns validated headlines and descriptions. + /// + public async Task<(List Headlines, List Descriptions, string Source)> + GenerateAsync(UrlAnalysis analysis, CancellationToken ct) + { + if (!_config.EnableRealApi || string.IsNullOrWhiteSpace(_config.OpenAiApiKey)) + return EmulateGeneration(analysis); + + return await GenerateRealAsync(analysis, ct); + } + + #region Real Implementation (OpenAI) + + private async Task<(List, List, string)> GenerateRealAsync( + UrlAnalysis analysis, CancellationToken ct) + { + _logger.LogInformation("[CopyGen] Calling OpenAI for {Url}", analysis.Url); + + var prompt = BuildPrompt(analysis); + + var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(_config.OpenAiTimeoutSeconds); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.OpenAiApiKey}"); + + var requestBody = new + { + model = _config.OpenAiModel, + max_tokens = _config.OpenAiMaxTokens, + messages = new[] + { + new + { + role = "system", + content = "You are an expert Google Ads copywriter. " + + "Return ONLY valid JSON with no markdown formatting. " + + "Follow character limits exactly." + }, + new { role = "user", content = prompt } + } + }; + + var json = JsonSerializer.Serialize(requestBody); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("https://api.openai.com/v1/chat/completions", content, ct); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("[CopyGen] OpenAI returned {Status}: {Body}", response.StatusCode, errorBody); + throw new InvalidOperationException($"OpenAI API returned {response.StatusCode}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(ct); + return ParseOpenAiResponse(responseJson); + } + + private static string BuildPrompt(UrlAnalysis analysis) + { + var sb = new StringBuilder(); + sb.AppendLine("Generate Google Ads copy for this business."); + sb.AppendLine(); + sb.AppendLine($"URL: {analysis.Url}"); + sb.AppendLine($"Title: {analysis.Title}"); + sb.AppendLine($"Description: {analysis.MetaDescription}"); + + if (analysis.Headings.Count > 0) + sb.AppendLine($"Headings: {string.Join(", ", analysis.Headings)}"); + + if (!string.IsNullOrWhiteSpace(analysis.BodySnippet)) + sb.AppendLine($"Content: {analysis.BodySnippet}"); + + sb.AppendLine(); + sb.AppendLine("Requirements:"); + sb.AppendLine("- 10 headlines, each MAXIMUM 30 characters"); + sb.AppendLine("- 4 descriptions, each MAXIMUM 90 characters"); + sb.AppendLine("- Headlines should be punchy and action-oriented"); + sb.AppendLine("- Descriptions should expand on value and include a call to action"); + sb.AppendLine("- Do NOT use excessive punctuation or ALL CAPS"); + sb.AppendLine(); + sb.AppendLine("Return JSON only, no markdown:"); + sb.AppendLine("""{"headlines":["..."],"descriptions":["..."]}"""); + + return sb.ToString(); + } + + private (List, List, string) ParseOpenAiResponse(string responseJson) + { + using var doc = JsonDocument.Parse(responseJson); + + // Extract the content from OpenAI's response structure + var messageContent = doc.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString() ?? "{}"; + + // Strip markdown fences if present + messageContent = messageContent + .Replace("```json", "") + .Replace("```", "") + .Trim(); + + using var parsed = JsonDocument.Parse(messageContent); + + var headlines = new List(); + var descriptions = new List(); + + if (parsed.RootElement.TryGetProperty("headlines", out var hArray)) + { + foreach (var h in hArray.EnumerateArray()) + { + var text = h.GetString() ?? ""; + headlines.Add(ValidateAsset("headline", text, MaxHeadlineChars)); + } + } + + if (parsed.RootElement.TryGetProperty("descriptions", out var dArray)) + { + foreach (var d in dArray.EnumerateArray()) + { + var text = d.GetString() ?? ""; + descriptions.Add(ValidateAsset("description", text, MaxDescriptionChars)); + } + } + + _logger.LogInformation("[CopyGen] OpenAI returned {H} headlines, {D} descriptions", + headlines.Count, descriptions.Count); + + return (headlines, descriptions, "openai"); + } + + #endregion + + #region Emulated + + private (List, List, string) EmulateGeneration(UrlAnalysis analysis) + { + _logger.LogInformation("[CopyGen] Emulated generation for {Url}", analysis.Url); + + var businessName = analysis.Title?.Split('-', '|', '–')[0].Trim() ?? "Our Business"; + if (businessName.Length > 20) businessName = businessName[..20].Trim(); + + var headlines = new List + { + $"{businessName} Near You", + $"Visit {businessName} Today", + "Quality You Can Trust", + "Get Started Today", + "See Our Services", + $"Discover {businessName}", + "Book an Appointment", + "Free Consultation", + "Top-Rated Service", + "Limited Time Offer" + } + .Select(h => ValidateAsset("headline", h, MaxHeadlineChars)) + .ToList(); + + var descriptions = new List + { + $"{businessName} delivers quality products and services. Visit us today and see the difference.", + "Trusted by thousands of customers. Get a free quote and experience our commitment to excellence.", + "Looking for reliable service? We offer competitive pricing and a satisfaction guarantee.", + "Join our happy customers today. Professional service, fair prices, and results that speak." + } + .Select(d => ValidateAsset("description", d, MaxDescriptionChars)) + .ToList(); + + return (headlines, descriptions, "emulated"); + } + + #endregion + + #region Validation + + /// + /// Validate and truncate asset text to meet Google Ads character limits. + /// + private static TextAsset ValidateAsset(string type, string text, int maxChars) + { + text = text.Trim(); + + // Truncate if over limit (shouldn't happen often with good prompts) + if (text.Length > maxChars) + text = text[..(maxChars - 1)].TrimEnd() + "…"; + + return new TextAsset + { + Type = type, + Text = text, + CharCount = text.Length + }; + } + + #endregion +} diff --git a/Creative/Services/CreativeService.cs b/Creative/Services/CreativeService.cs new file mode 100644 index 0000000..3400e8a --- /dev/null +++ b/Creative/Services/CreativeService.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using Creative.Models; + +namespace Creative.Services; + +/// +/// Main creative service - dispatches operations to appropriate handlers. +/// Stateless: returns JSON, Gateway handles persistence. +/// +public class CreativeService +{ + private readonly ScraperService _scraper; + private readonly CopyGeneratorService _copyGen; + private readonly ImageGeneratorService _imageGen; + private readonly ILogger _logger; + + public CreativeService( + ScraperService scraper, + CopyGeneratorService copyGen, + ImageGeneratorService imageGen, + ILogger logger) + { + _scraper = scraper; + _copyGen = copyGen; + _imageGen = imageGen; + _logger = logger; + } + + /// + /// Main dispatch method - routes to appropriate operation handler. + /// + public async Task ExecuteAsync(CreativeRequest request, CancellationToken ct) + { + var requestId = request.RequestId ?? Guid.NewGuid().ToString("N"); + var operation = (request.Operation ?? "").Trim(); + + _logger.LogInformation("[Creative] Executing {Operation} | RequestId={RequestId}", + operation, requestId); + + try + { + return operation switch + { + "Ping" => Ping(requestId), + "AnalyzeUrl" => await AnalyzeUrlAsync(request, requestId, ct), + "GenerateAssets" => await GenerateAssetsAsync(request, requestId, ct), + "GetImages" => await GetImagesAsync(request, requestId, ct), + "CreateDraft" => await CreateDraftAsync(request, requestId, ct), + _ => CreativeResponse.Fail(requestId, "UNKNOWN_OPERATION", + $"Unknown operation: {operation}") + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "[Creative] {Operation} failed | RequestId={RequestId}", + operation, requestId); + return CreativeResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message); + } + } + + // ============================================================ + // Operations + // ============================================================ + + /// + /// Health check. + /// + private static CreativeResponse Ping(string requestId) + { + return CreativeResponse.Success(requestId, new + { + pong = true, + timestamp = DateTimeOffset.UtcNow + }); + } + + /// + /// Scrape and analyze a URL. Returns structured content. + /// Payload: { "url": "https://..." } + /// + private async Task AnalyzeUrlAsync( + CreativeRequest request, string requestId, CancellationToken ct) + { + var url = GetPayloadString(request, "url"); + if (string.IsNullOrWhiteSpace(url)) + return CreativeResponse.Fail(requestId, "MISSING_URL", "payload.url is required"); + + url = NormalizeUrl(url); + var analysis = await _scraper.AnalyzeUrlAsync(url, ct); + + return CreativeResponse.Success(requestId, analysis); + } + + /// + /// Generate text assets from previously analyzed content. + /// Payload: { "analysis": { ... } } (UrlAnalysis object) + /// + private async Task GenerateAssetsAsync( + CreativeRequest request, string requestId, CancellationToken ct) + { + var analysisJson = GetPayloadObject(request, "analysis"); + if (analysisJson == null) + return CreativeResponse.Fail(requestId, "MISSING_ANALYSIS", + "payload.analysis is required (pass result from AnalyzeUrl)"); + + var analysis = JsonSerializer.Deserialize(analysisJson.Value.GetRawText()); + if (analysis == null) + return CreativeResponse.Fail(requestId, "INVALID_ANALYSIS", + "Could not deserialize analysis object"); + + var (headlines, descriptions, source) = await _copyGen.GenerateAsync(analysis, ct); + + return CreativeResponse.Success(requestId, new + { + headlines, + descriptions, + source + }); + } + + /// + /// Get images matching previously analyzed content. + /// Payload: { "analysis": { ... } } (UrlAnalysis object) + /// + private async Task GetImagesAsync( + CreativeRequest request, string requestId, CancellationToken ct) + { + var analysisJson = GetPayloadObject(request, "analysis"); + if (analysisJson == null) + return CreativeResponse.Fail(requestId, "MISSING_ANALYSIS", + "payload.analysis is required (pass result from AnalyzeUrl)"); + + var analysis = JsonSerializer.Deserialize(analysisJson.Value.GetRawText()); + if (analysis == null) + return CreativeResponse.Fail(requestId, "INVALID_ANALYSIS", + "Could not deserialize analysis object"); + + var (images, source) = await _imageGen.GenerateAsync(analysis, ct); + + return CreativeResponse.Success(requestId, new + { + images, + source + }); + } + + /// + /// Full pipeline: scrape URL → generate copy → source images → return campaign draft. + /// Payload: { "url": "https://..." } + /// Gateway persists the returned draft to tbCreativeDraft. + /// + private async Task CreateDraftAsync( + CreativeRequest request, string requestId, CancellationToken ct) + { + var url = GetPayloadString(request, "url"); + if (string.IsNullOrWhiteSpace(url)) + return CreativeResponse.Fail(requestId, "MISSING_URL", "payload.url is required"); + + url = NormalizeUrl(url); + + // Step 1: Scrape + _logger.LogInformation("[Creative] CreateDraft step 1/3: scraping {Url}", url); + var analysis = await _scraper.AnalyzeUrlAsync(url, ct); + + // Step 2: Generate copy + _logger.LogInformation("[Creative] CreateDraft step 2/3: generating copy"); + var (headlines, descriptions, copySource) = await _copyGen.GenerateAsync(analysis, ct); + + // Step 3: Source images + _logger.LogInformation("[Creative] CreateDraft step 3/3: sourcing images"); + var (images, imageSource) = await _imageGen.GenerateAsync(analysis, ct); + + // Assemble draft - Gateway will persist this + var draftId = Guid.NewGuid().ToString("N")[..12]; + var draft = new CampaignDraft + { + DraftId = draftId, + Url = url, + Analysis = analysis, + Headlines = headlines, + Descriptions = descriptions, + Images = images, + Source = copySource, + ImageSource = imageSource, + CreatedAt = DateTimeOffset.UtcNow + }; + + _logger.LogInformation( + "[Creative] Draft assembled | DraftId={DraftId} Headlines={H} Descriptions={D} Images={I} CopySource={CS} ImageSource={IS}", + draftId, headlines.Count, descriptions.Count, images.Count, copySource, imageSource); + + return CreativeResponse.Success(requestId, draft); + } + + // ============================================================ + // Helpers + // ============================================================ + + private static string? GetPayloadString(CreativeRequest request, string key) + { + if (request.Payload == null) return null; + if (!request.Payload.TryGetValue(key, out var value)) return null; + + return value switch + { + string s => s, + JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(), + _ => value?.ToString() + }; + } + + private static JsonElement? GetPayloadObject(CreativeRequest request, string key) + { + if (request.Payload == null) return null; + if (!request.Payload.TryGetValue(key, out var value)) return null; + + if (value is JsonElement je && je.ValueKind == JsonValueKind.Object) + return je; + + return null; + } + + private static string NormalizeUrl(string url) + { + url = url.Trim(); + if (!url.StartsWith("http://") && !url.StartsWith("https://")) + url = "https://" + url; + return url; + } +} diff --git a/Creative/Services/ImageGeneratorService.cs b/Creative/Services/ImageGeneratorService.cs new file mode 100644 index 0000000..fce0322 --- /dev/null +++ b/Creative/Services/ImageGeneratorService.cs @@ -0,0 +1,414 @@ +using System.Text; +using System.Text.Json; +using Creative.Configuration; +using Creative.Models; +using Microsoft.Extensions.Options; + +namespace Creative.Services; + +/// +/// Sources images for campaign drafts. +/// Three providers: +/// - emulated: placeholder images (no network calls) +/// - unsplash: free stock photos via Unsplash API +/// - dalle: AI-generated images via OpenAI DALL-E (requires OpenAI key) +/// +/// Google Ads Responsive Display Ad image specs: +/// Landscape (1.91:1): 1200×628 recommended +/// Square (1:1): 1200×1200 recommended +/// Portrait (4:5): 960×1200 recommended (optional) +/// +public class ImageGeneratorService +{ + private readonly CreativeConfig _config; + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _logger; + + public ImageGeneratorService( + IOptions config, + IHttpClientFactory httpFactory, + ILogger logger) + { + _config = config.Value; + _httpFactory = httpFactory; + _logger = logger; + } + + /// + /// Get images matching the analyzed content. + /// Returns a list of ImageAssets and the provider name used. + /// + public async Task<(List Images, string Source)> + GenerateAsync(UrlAnalysis analysis, CancellationToken ct) + { + var provider = (_config.ImageProvider ?? "emulated").ToLowerInvariant(); + + _logger.LogInformation("[ImageGen] Provider={Provider} for {Url}", provider, analysis.Url); + + return provider switch + { + "unsplash" => await GenerateUnsplashAsync(analysis, ct), + "dalle" => await GenerateDalleAsync(analysis, ct), + _ => EmulateGeneration(analysis) + }; + } + + // ============================================================ + // Emulated Provider + // ============================================================ + + private (List, string) EmulateGeneration(UrlAnalysis analysis) + { + _logger.LogInformation("[ImageGen] Emulated images for {Url}", analysis.Url); + + var keyword = ExtractSearchKeyword(analysis); + var images = new List + { + new() + { + ImageId = $"emu-landscape-{Guid.NewGuid():N}"[..20], + Url = $"https://placehold.co/1200x628/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}", + Source = "emulated", + Orientation = "landscape", + Width = 1200, + Height = 628, + AltText = $"{keyword} - landscape", + Attribution = "Placeholder image" + }, + new() + { + ImageId = $"emu-square-{Guid.NewGuid():N}"[..20], + Url = $"https://placehold.co/1200x1200/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}", + Source = "emulated", + Orientation = "square", + Width = 1200, + Height = 1200, + AltText = $"{keyword} - square", + Attribution = "Placeholder image" + }, + new() + { + ImageId = $"emu-portrait-{Guid.NewGuid():N}"[..20], + Url = $"https://placehold.co/960x1200/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}", + Source = "emulated", + Orientation = "portrait", + Width = 960, + Height = 1200, + AltText = $"{keyword} - portrait", + Attribution = "Placeholder image" + } + }; + + return (images.Take(_config.ImageCount).ToList(), "emulated"); + } + + // ============================================================ + // Unsplash Provider + // ============================================================ + + private async Task<(List, string)> GenerateUnsplashAsync( + UrlAnalysis analysis, CancellationToken ct) + { + var keyword = ExtractSearchKeyword(analysis); + _logger.LogInformation("[ImageGen] Unsplash search: '{Keyword}'", keyword); + + var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(15); + + // Unsplash supports unauthenticated requests at lower rate limits. + // With an access key you get 50 req/hour (free tier). + var hasKey = !string.IsNullOrWhiteSpace(_config.UnsplashAccessKey); + if (hasKey) + client.DefaultRequestHeaders.Add("Authorization", $"Client-ID {_config.UnsplashAccessKey}"); + + var images = new List(); + var orientations = new[] { "landscape", "squarish", "portrait" }; + + foreach (var orientation in orientations.Take(_config.ImageCount)) + { + try + { + var queryUrl = hasKey + ? $"https://api.unsplash.com/search/photos?query={Uri.EscapeDataString(keyword)}&orientation={orientation}&per_page=1" + : $"https://api.unsplash.com/search/photos?query={Uri.EscapeDataString(keyword)}&orientation={orientation}&per_page=1"; + + var response = await client.GetAsync(queryUrl, ct); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("[ImageGen] Unsplash returned {Status} for orientation={Orientation}", + response.StatusCode, orientation); + + // If unauthenticated and rate-limited, fall back to source.unsplash.com + images.Add(BuildUnsplashFallback(keyword, orientation)); + continue; + } + + var json = await response.Content.ReadAsStringAsync(ct); + var parsed = JsonDocument.Parse(json); + var results = parsed.RootElement.GetProperty("results"); + + if (results.GetArrayLength() == 0) + { + _logger.LogInformation("[ImageGen] No Unsplash results for '{Keyword}' {Orientation}", + keyword, orientation); + images.Add(BuildUnsplashFallback(keyword, orientation)); + continue; + } + + var photo = results[0]; + var mappedOrientation = orientation == "squarish" ? "square" : orientation; + + images.Add(new ImageAsset + { + ImageId = photo.GetProperty("id").GetString() ?? $"unsplash-{Guid.NewGuid():N}"[..16], + Url = photo.GetProperty("urls").GetProperty("regular").GetString() ?? "", + DownloadUrl = photo.GetProperty("urls").GetProperty("full").GetString(), + Source = "unsplash", + Orientation = mappedOrientation, + Width = photo.GetProperty("width").GetInt32(), + Height = photo.GetProperty("height").GetInt32(), + AltText = photo.GetProperty("alt_description").ValueKind == JsonValueKind.Null + ? keyword + : photo.GetProperty("alt_description").GetString(), + Attribution = BuildUnsplashAttribution(photo) + }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[ImageGen] Unsplash error for {Orientation}, using fallback", orientation); + images.Add(BuildUnsplashFallback(keyword, orientation)); + } + } + + _logger.LogInformation("[ImageGen] Unsplash returned {Count} images", images.Count); + return (images, "unsplash"); + } + + /// + /// Fallback using source.unsplash.com redirect (no API key needed, no rate limit). + /// Returns a random photo matching the keyword at the requested dimensions. + /// + private static ImageAsset BuildUnsplashFallback(string keyword, string orientation) + { + var (w, h, mapped) = orientation switch + { + "landscape" => (1200, 628, "landscape"), + "squarish" => (1200, 1200, "square"), + "portrait" => (960, 1200, "portrait"), + _ => (1200, 628, "landscape") + }; + + return new ImageAsset + { + ImageId = $"unsplash-fallback-{Guid.NewGuid():N}"[..24], + Url = $"https://source.unsplash.com/{w}x{h}/?{Uri.EscapeDataString(keyword)}", + Source = "unsplash", + Orientation = mapped, + Width = w, + Height = h, + AltText = keyword, + Attribution = "Photo from Unsplash" + }; + } + + private static string BuildUnsplashAttribution(JsonElement photo) + { + var userName = "Unknown"; + var userLink = ""; + + if (photo.TryGetProperty("user", out var user)) + { + userName = user.TryGetProperty("name", out var name) + ? name.GetString() ?? "Unknown" + : "Unknown"; + + if (user.TryGetProperty("links", out var links) && + links.TryGetProperty("html", out var html)) + { + userLink = html.GetString() ?? ""; + } + } + + // Unsplash TOS requires photographer attribution + return string.IsNullOrEmpty(userLink) + ? $"Photo by {userName} on Unsplash" + : $"Photo by {userName} on Unsplash ({userLink})"; + } + + // ============================================================ + // DALL-E Provider (stubbed — ready for OpenAI key) + // ============================================================ + + private async Task<(List, string)> GenerateDalleAsync( + UrlAnalysis analysis, CancellationToken ct) + { + // Guard: DALL-E requires the OpenAI key + if (string.IsNullOrWhiteSpace(_config.OpenAiApiKey)) + { + _logger.LogWarning("[ImageGen] DALL-E requested but no OpenAI key configured, falling back to emulated"); + return EmulateGeneration(analysis); + } + + var keyword = ExtractSearchKeyword(analysis); + var prompt = BuildDallePrompt(analysis, keyword); + + _logger.LogInformation("[ImageGen] DALL-E generation: '{Prompt}'", prompt[..Math.Min(80, prompt.Length)]); + + var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(_config.OpenAiTimeoutSeconds); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.OpenAiApiKey}"); + + var images = new List(); + + // DALL-E 3 supports: 1024x1024, 1792x1024 (landscape), 1024x1792 (portrait) + var dalleVariants = new[] + { + (size: "1792x1024", orientation: "landscape", w: 1792, h: 1024), + (size: "1024x1024", orientation: "square", w: 1024, h: 1024), + (size: "1024x1792", orientation: "portrait", w: 1024, h: 1792) + }; + + foreach (var variant in dalleVariants.Take(_config.ImageCount)) + { + try + { + var requestBody = new + { + model = _config.DalleModel, + prompt = prompt, + n = 1, + size = variant.size, + quality = "standard", + response_format = "url" + }; + + var json = JsonSerializer.Serialize(requestBody); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync( + "https://api.openai.com/v1/images/generations", content, ct); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("[ImageGen] DALL-E returned {Status}: {Body}", + response.StatusCode, errorBody); + + // Fall back to emulated for this orientation + images.Add(BuildEmulatedFallback(keyword, variant.orientation, + variant.w, variant.h)); + continue; + } + + var responseJson = await response.Content.ReadAsStringAsync(ct); + var parsed = JsonDocument.Parse(responseJson); + var data = parsed.RootElement.GetProperty("data")[0]; + + var imageUrl = data.GetProperty("url").GetString() ?? ""; + var revisedPrompt = data.TryGetProperty("revised_prompt", out var rp) + ? rp.GetString() : null; + + images.Add(new ImageAsset + { + ImageId = $"dalle-{variant.orientation}-{Guid.NewGuid():N}"[..24], + Url = imageUrl, + Source = "dalle", + Orientation = variant.orientation, + Width = variant.w, + Height = variant.h, + AltText = revisedPrompt ?? keyword, + Attribution = $"AI-generated image via {_config.DalleModel}" + }); + + _logger.LogInformation("[ImageGen] DALL-E generated {Orientation} image", variant.orientation); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[ImageGen] DALL-E error for {Orientation}, using fallback", + variant.orientation); + images.Add(BuildEmulatedFallback(keyword, variant.orientation, + variant.w, variant.h)); + } + } + + _logger.LogInformation("[ImageGen] DALL-E returned {Count} images", images.Count); + return (images, "dalle"); + } + + /// + /// Build a DALL-E prompt from the analysis. Aims for clean, + /// professional ad imagery — not artistic or abstract. + /// + private static string BuildDallePrompt(UrlAnalysis analysis, string keyword) + { + var sb = new StringBuilder(); + sb.Append("Professional advertising photograph for a "); + sb.Append(analysis.InferredCategory ?? "business"); + sb.Append(" business. "); + + if (!string.IsNullOrWhiteSpace(analysis.Title)) + { + var businessName = analysis.Title.Split('-', '|', '–')[0].Trim(); + sb.Append($"Business: {businessName}. "); + } + + sb.Append($"Theme: {keyword}. "); + sb.Append("Clean, well-lit, commercial style. "); + sb.Append("No text or watermarks. Suitable for Google Ads display."); + + return sb.ToString(); + } + + private static ImageAsset BuildEmulatedFallback(string keyword, string orientation, int w, int h) + { + return new ImageAsset + { + ImageId = $"fallback-{orientation}-{Guid.NewGuid():N}"[..24], + Url = $"https://placehold.co/{w}x{h}/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}", + Source = "emulated", + Orientation = orientation, + Width = w, + Height = h, + AltText = $"{keyword} - {orientation}", + Attribution = "Placeholder image (provider fallback)" + }; + } + + // ============================================================ + // Shared Helpers + // ============================================================ + + /// + /// Extract a concise search keyword from the analysis. + /// Uses category first, then title, then domain. + /// + private static string ExtractSearchKeyword(UrlAnalysis analysis) + { + // Prefer inferred category (e.g., "Pizza", "Soccer") + if (!string.IsNullOrWhiteSpace(analysis.InferredCategory)) + return analysis.InferredCategory; + + // Fall back to first meaningful heading + var heading = analysis.Headings?.FirstOrDefault(h => h.Length > 3 && h.Length < 40); + if (!string.IsNullOrWhiteSpace(heading)) + return heading; + + // Fall back to title (cleaned) + if (!string.IsNullOrWhiteSpace(analysis.Title)) + { + var title = analysis.Title.Split('-', '|', '–')[0].Trim(); + return title.Length > 30 ? title[..30] : title; + } + + // Last resort: domain name + try + { + var uri = new Uri(analysis.Url.StartsWith("http") ? analysis.Url : $"https://{analysis.Url}"); + return uri.Host.Replace("www.", "").Split('.')[0]; + } + catch + { + return "business"; + } + } +} diff --git a/Creative/Services/ScraperService.cs b/Creative/Services/ScraperService.cs new file mode 100644 index 0000000..6ee0191 --- /dev/null +++ b/Creative/Services/ScraperService.cs @@ -0,0 +1,161 @@ +using Creative.Configuration; +using Creative.Models; +using HtmlAgilityPack; +using Microsoft.Extensions.Options; + +namespace Creative.Services; + +/// +/// Scrapes a URL and extracts structured business data. +/// Supports emulated mode for development without network calls. +/// +public class ScraperService +{ + private readonly CreativeConfig _config; + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _logger; + + public ScraperService( + IOptions config, + IHttpClientFactory httpFactory, + ILogger logger) + { + _config = config.Value; + _httpFactory = httpFactory; + _logger = logger; + } + + /// + /// Analyze a URL - scrape and extract structured content. + /// + public async Task AnalyzeUrlAsync(string url, CancellationToken ct) + { + if (!_config.EnableRealApi) + return EmulateAnalysis(url); + + return await ScrapeRealAsync(url, ct); + } + + #region Real Implementation + + private async Task ScrapeRealAsync(string url, CancellationToken ct) + { + _logger.LogInformation("[Scraper] Fetching {Url}", url); + + var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(_config.ScrapeTimeoutSeconds); + client.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (compatible; AdPlatformBot/1.0)"); + + var html = await client.GetStringAsync(url, ct); + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // Extract title + var title = doc.DocumentNode + .SelectSingleNode("//title")?.InnerText?.Trim(); + + // Extract meta description + var metaDesc = doc.DocumentNode + .SelectSingleNode("//meta[@name='description']")? + .GetAttributeValue("content", null)?.Trim(); + + // Extract H1-H3 headings + var headings = new List(); + foreach (var tag in new[] { "h1", "h2", "h3" }) + { + var nodes = doc.DocumentNode.SelectNodes($"//{tag}"); + if (nodes != null) + { + foreach (var node in nodes.Take(5)) + { + var text = HtmlEntity.DeEntitize(node.InnerText).Trim(); + if (!string.IsNullOrWhiteSpace(text)) + headings.Add(text); + } + } + } + + // Extract body text snippet (first meaningful paragraphs) + var bodySnippet = ExtractBodySnippet(doc); + + _logger.LogInformation("[Scraper] Extracted: title={Title} headings={Count}", + title?.Length > 40 ? title[..40] + "..." : title, headings.Count); + + return new UrlAnalysis + { + Url = url, + Title = title, + MetaDescription = metaDesc, + Headings = headings, + BodySnippet = bodySnippet, + InferredCategory = null, // Category inference handled by CopyGenerator + ScrapedAt = DateTimeOffset.UtcNow + }; + } + + private static string? ExtractBodySnippet(HtmlDocument doc) + { + // Remove script/style nodes + var removeNodes = doc.DocumentNode.SelectNodes("//script|//style|//nav|//footer|//header"); + if (removeNodes != null) + { + foreach (var node in removeNodes) + node.Remove(); + } + + var paragraphs = doc.DocumentNode.SelectNodes("//p"); + if (paragraphs == null) return null; + + var texts = paragraphs + .Select(p => HtmlEntity.DeEntitize(p.InnerText).Trim()) + .Where(t => t.Length > 30) + .Take(3); + + var snippet = string.Join(" ", texts); + return snippet.Length > 500 ? snippet[..500] : snippet; + } + + #endregion + + #region Emulated + + private UrlAnalysis EmulateAnalysis(string url) + { + _logger.LogInformation("[Scraper] Emulated analysis for {Url}", url); + + // Parse domain for realistic emulated data + var domain = "example.com"; + try + { + var uri = new Uri(url.StartsWith("http") ? url : $"https://{url}"); + domain = uri.Host.Replace("www.", ""); + } + catch { /* use default */ } + + var businessName = domain.Split('.')[0]; + var titleCase = char.ToUpper(businessName[0]) + businessName[1..]; + + return new UrlAnalysis + { + Url = url, + Title = $"{titleCase} - Quality Products & Services", + MetaDescription = $"{titleCase} offers premium products and services. Visit us today for the best experience.", + Headings = new List + { + $"Welcome to {titleCase}", + "Our Services", + "Why Choose Us", + "Contact Us Today" + }, + BodySnippet = $"{titleCase} has been serving customers with dedication and quality. " + + "We offer a wide range of products and services designed to meet your needs. " + + "Our team is committed to providing exceptional value and customer satisfaction.", + InferredCategory = "Business Services", + ScrapedAt = DateTimeOffset.UtcNow + }; + } + + #endregion +} diff --git a/Creative/appsettings.Development.json b/Creative/appsettings.Development.json new file mode 100644 index 0000000..a6e86ac --- /dev/null +++ b/Creative/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Creative/appsettings.json b/Creative/appsettings.json new file mode 100644 index 0000000..4353e1a --- /dev/null +++ b/Creative/appsettings.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + + "InternalKey": "", + + "Creative": { + "EnableRealApi": false, + "OpenAiApiKey": "", + "OpenAiModel": "gpt-4o-mini", + "OpenAiMaxTokens": 1000, + "ScrapeTimeoutSeconds": 15, + "OpenAiTimeoutSeconds": 30, + "ImageProvider": "emulated", + "UnsplashAccessKey": "", + "ImageCount": 3, + "DalleModel": "dall-e-3", + "DalleSize": "1024x1024" + } +} diff --git a/Gateway/Controllers/AuthController.cs b/Gateway/Controllers/AuthController.cs index 1c391ef..0a64b3f 100644 --- a/Gateway/Controllers/AuthController.cs +++ b/Gateway/Controllers/AuthController.cs @@ -44,10 +44,18 @@ public sealed class AuthController : ControllerBase _log.LogWarning("[Session] Authenticated: ClientId={ClientId}, Email={Email}", _client.ClientId, _client.Email); + // Gateway handles CIAM client sessions only. + // Staff apps authenticate directly to Management API via JWT Bearer — never via Gateway. + if (_client.IsStaff) + { + _log.LogWarning("[Session] Staff token rejected — use JWT Bearer directly to Management API"); + return StatusCode(403, new { ok = false, error = "Staff authentication does not use Gateway sessions" }); + } + var rqst = JsonSerializer.Serialize(new { provider = _client.AuthProvider ?? "EntraExternalId", - subject = _client.ClientId, + subject = _client.ClientId, email = _client.Email, displayName = _client.ClientName, clientId = request?.PreferredClientId, @@ -56,13 +64,15 @@ public sealed class AuthController : ControllerBase sessionDurationHours = request?.SessionDurationHours ?? 24 }); - _log.LogWarning("[Session] Calling spSession with: {Rqst}", rqst); + _log.LogWarning("[Session] Calling proc with: {Rqst}", rqst); + + _log.LogWarning("[Session] Using proc=dbo.spClientSession"); try { - var resp = await _sql.ExecProcAsync("dbo.spSession", "createFromIdentity", rqst, ct: ct); + var resp = await _sql.ExecProcAsync("dbo.spClientSession", "createFromIdentity", rqst, ct: ct); - _log.LogWarning("[Session] spSession response: {Resp}", resp ?? "(null)"); + _log.LogWarning("[Session] Proc response: {Resp}", resp ?? "(null)"); if (string.IsNullOrWhiteSpace(resp)) { @@ -118,7 +128,7 @@ public sealed class AuthController : ControllerBase var rqst = JsonSerializer.Serialize(new { provider = _client.AuthProvider ?? "EntraExternalId", - subject = _client.ClientId, + subject = _client.ClientId, email = _client.Email, displayName = _client.ClientName, companyName = request.CompanyName, @@ -173,10 +183,11 @@ public sealed class AuthController : ControllerBase } var rqst = JsonSerializer.Serialize(new { sessionToken = token }); + var signoffProc = "dbo.spClientSession"; // Gateway handles client sessions only try { - await _sql.ExecProcAsync("dbo.spSession", "signoff", rqst, ct: ct); + await _sql.ExecProcAsync(signoffProc, "signoff", rqst, ct: ct); return Ok(new { ok = true, message = "Signed out successfully" }); } catch (Exception ex) @@ -204,10 +215,11 @@ public sealed class AuthController : ControllerBase sessionToken = token, sessionDurationHours = request?.SessionDurationHours ?? 24 }); + var refreshProc = "dbo.spClientSession"; // Gateway handles client sessions only try { - var resp = await _sql.ExecProcAsync("dbo.spSession", "refresh", rqst, ct: ct); + var resp = await _sql.ExecProcAsync(refreshProc, "refresh", rqst, ct: ct); if (string.IsNullOrWhiteSpace(resp)) { @@ -251,7 +263,7 @@ public sealed class AuthController : ControllerBase try { - var resp = await _sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: ct); + var resp = await _sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: ct); if (string.IsNullOrWhiteSpace(resp)) { @@ -304,7 +316,7 @@ public sealed class AuthController : ControllerBase try { - var resp = await _sql.ExecProcAsync("dbo.spSession", "switchClient", rqst, ct: ct); + var resp = await _sql.ExecProcAsync("dbo.spClientSession", "switchClient", rqst, ct: ct); if (string.IsNullOrWhiteSpace(resp)) { @@ -338,11 +350,18 @@ public sealed class AuthController : ControllerBase if (!string.IsNullOrWhiteSpace(token)) return token; - // Check Authorization header (for session tokens, not JWTs) + // Check Authorization header — accept both "Session " and "Bearer ". + // NOTE: Bearer here is a session token (not an Entra JWT) because the middleware + // only routes to these controller actions after session validation succeeds. + // The JWT-only endpoint (/api/auth/session) never calls ExtractSessionToken(). var auth = Request.Headers.Authorization.FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(auth) && auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrWhiteSpace(auth)) { - return auth.Substring(8).Trim(); + if (auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase)) + return auth.Substring(8).Trim(); + + if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return auth.Substring(7).Trim(); } return null; diff --git a/Gateway/Controllers/CampaignIntelligenceController.cs b/Gateway/Controllers/CampaignIntelligenceController.cs new file mode 100644 index 0000000..330432c --- /dev/null +++ b/Gateway/Controllers/CampaignIntelligenceController.cs @@ -0,0 +1,185 @@ +using Gateway.Data; +using Gateway.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Gateway.Controllers; + +/// +/// Campaign intelligence endpoints: health overview, budget pacing, +/// and post-campaign analysis. +/// +/// SECURITY MODEL: +/// - Every endpoint requires authenticated session (via middleware) +/// - Initiative endpoints verify ownership before data access +/// - Client-level endpoints scoped via injected ClientContext +/// - ClientId is always injected server-side, never from request body +/// +[ApiController] +[Route("api/intelligence")] +public sealed class CampaignIntelligenceController : ControllerBase +{ + private readonly SqlService _sql; + private readonly ClientContext _client; + private readonly AuthorizationGuard _guard; + private readonly ILogger _log; + + public CampaignIntelligenceController( + SqlService sql, + ClientContext client, + AuthorizationGuard guard, + ILogger log) + { + _sql = sql; + _client = client; + _guard = guard; + _log = log; + } + + // ──────────────────────────────────────────────── + // Campaign Health Overview + // ──────────────────────────────────────────────── + + /// + /// Get health overview for all active initiatives. + /// Returns green/yellow/red status per channel campaign based on active recommendations. + /// + [HttpGet("health")] + public async Task Health(CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + return await Exec(SqlNames.Procs.CampaignIntelligence, "health", + JsonSerializer.Serialize(new { clientId = _client.ClientId }), ct); + } + + // ──────────────────────────────────────────────── + // Budget Pacing + // ──────────────────────────────────────────────── + + /// + /// Get budget pacing analysis for an initiative. + /// Shows actual vs expected spend velocity with projections. + /// + [HttpGet("{initiativeId:long}/pacing")] + public async Task Pacing(long initiativeId, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + return await Exec(SqlNames.Procs.CampaignIntelligence, "pacing", + JsonSerializer.Serialize(new { initiativeId }), ct); + } + + // ──────────────────────────────────────────────── + // Post-Campaign Report + // ──────────────────────────────────────────────── + + /// + /// Comprehensive post-campaign analysis. + /// Cross-platform comparison with daily trends, efficiency metrics, + /// and recommendation history. + /// + [HttpGet("{initiativeId:long}/report")] + public async Task PostCampaignReport(long initiativeId, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + return await Exec(SqlNames.Procs.CampaignIntelligence, "postCampaign", + JsonSerializer.Serialize(new { initiativeId }), ct); + } + + // ──────────────────────────────────────────────── + // Metric Snapshots (internal / polling service) + // ──────────────────────────────────────────────── + + /// + /// Record an intraday metric snapshot for pacing analysis. + /// Called by the background polling service between daily aggregations. + /// Admin-only endpoint. + /// + [HttpPost("snapshot")] + public async Task Snapshot([FromBody] SnapshotRequest request, CancellationToken ct) + { + var (ok, err) = _guard.RequireServiceKey(); + if (!ok) return StatusCode(403, new { ok = false, error = err }); + + return await Exec(SqlNames.Procs.CampaignIntelligence, "snapshot", + JsonSerializer.Serialize(new + { + channelCampaignId = request.ChannelCampaignId, + date = request.Date, + impressions = request.Impressions, + clicks = request.Clicks, + spend = request.Spend, + conversions = request.Conversions + }), ct); + } + + /// + /// Batch insert intraday snapshots. + /// Admin-only endpoint. + /// + [HttpPost("snapshot/batch")] + public async Task SnapshotBatch([FromBody] SnapshotBatchRequest request, CancellationToken ct) + { + var (ok, err) = _guard.RequireServiceKey(); + if (!ok) return StatusCode(403, new { ok = false, error = err }); + + return await Exec(SqlNames.Procs.CampaignIntelligence, "snapshotBatch", + JsonSerializer.Serialize(new { snapshots = request.Snapshots }), ct); + } + + // ──────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────── + + private async Task Exec(string proc, string action, string rqst, CancellationToken ct) + { + try + { + var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct); + if (string.IsNullOrWhiteSpace(resp)) + return StatusCode(500, new { ok = false, error = "Service unavailable" }); + + using var doc = JsonDocument.Parse(resp); + var root = doc.RootElement; + + if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + { + var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error"; + if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true) + return NotFound(JsonSerializer.Deserialize(resp)); + return BadRequest(JsonSerializer.Deserialize(resp)); + } + + return Content(resp, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "CampaignIntelligence {Action} error", action); + return StatusCode(500, new { ok = false, error = "Service error" }); + } + } +} + +// ── DTOs ── + +public sealed class SnapshotRequest +{ + public long? ChannelCampaignId { get; set; } + public string? Date { get; set; } + public long? Impressions { get; set; } + public long? Clicks { get; set; } + public decimal? Spend { get; set; } + public decimal? Conversions { get; set; } +} + +public sealed class SnapshotBatchRequest +{ + public object[]? Snapshots { get; set; } +} diff --git a/Gateway/Controllers/ClientDocumentController.cs b/Gateway/Controllers/ClientDocumentController.cs new file mode 100644 index 0000000..4a92628 --- /dev/null +++ b/Gateway/Controllers/ClientDocumentController.cs @@ -0,0 +1,212 @@ +using Gateway.Data; +using Gateway.Security; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.SqlClient; +using System.Data; +using System.Text.Json; + +namespace Gateway.Controllers; + +/// +/// Client-facing document endpoints. +/// All operations are scoped to the authenticated client — clientId is always +/// injected from ClientContext, never trusted from the request body. +/// +/// POST /api/documents/list - List client's own documents +/// POST /api/documents - Upload a document (multipart) +/// GET /api/documents/{id}/download - Download (enforces client ownership) +/// DELETE /api/documents/{id} - Soft delete (enforces client ownership) +/// +[ApiController] +[Route("api/documents")] +public sealed class ClientDocumentController : ControllerBase +{ + private readonly SqlService _sql; + private readonly ClientContext _client; + private readonly IConfiguration _config; + private readonly AuthorizationGuard _guard; + private readonly ILogger _log; + + public ClientDocumentController( + SqlService sql, + ClientContext client, + IConfiguration config, + AuthorizationGuard guard, + ILogger log) + { + _sql = sql; + _client = client; + _config = config; + _guard = guard; + _log = log; + } + + // ── POST /api/documents/list ───────────────────────────────────────────── + [HttpPost("list")] + public async Task List([FromBody] JsonElement body, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + try + { + var rqst = JsonSerializer.Serialize(new + { + scope = "client", + clientId = _client.ClientId // always from session, never from body + }); + + var result = await _sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct); + return Content(result, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Client document list failed"); + return StatusCode(500, new { ok = false, error = "Document service error" }); + } + } + + // ── POST /api/documents ────────────────────────────────────────────────── + [HttpPost] + [RequestSizeLimit(52_428_800)] + public async Task Upload( + IFormFile file, + [FromForm] string category, + [FromForm] string? description = null, + CancellationToken ct = default) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + if (file == null || file.Length == 0) + return BadRequest(new { ok = false, error = "No file provided" }); + + try + { + byte[] fileBytes; + using (var ms = new MemoryStream()) + { + await file.CopyToAsync(ms, ct); + fileBytes = ms.ToArray(); + } + + var rqst = JsonSerializer.Serialize(new + { + docFileName = file.FileName, + docMimeType = file.ContentType, + docFileSize = file.Length, + docCategory = category, + docDescription = description, + docUploadedBy = _client.Email, + docScope = "client", + docCltId = _client.ClientId // injected from session + }); + + _log.LogInformation("[ClientDocs] Upload {FileName} | Client={ClientId}", + file.FileName, _client.ClientId); + + var result = await ExecUploadAsync(rqst, fileBytes, ct); + return Content(result, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Client document upload failed: {FileName}", file?.FileName); + return StatusCode(500, new { ok = false, error = "Upload failed" }); + } + } + + // ── GET /api/documents/{id}/download ───────────────────────────────────── + [HttpGet("{id:long}/download")] + public async Task Download(long id, CancellationToken ct = default) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + try + { + var cs = _config.GetConnectionString("Sql") + ?? throw new InvalidOperationException("Missing ConnectionStrings:Sql"); + + await using var conn = new SqlConnection(cs); + await conn.OpenAsync(ct); + + await using var cmd = new SqlCommand("dbo.usp_Document", conn) + { + CommandType = CommandType.StoredProcedure, + CommandTimeout = 60 + }; + cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" }); + cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = + JsonSerializer.Serialize(new { docId = id, clientId = _client.ClientId }) }); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) + return NotFound(new { ok = false, error = "Document not found" }); + + var fileName = reader.GetString(reader.GetOrdinal("docFileName")); + var mimeType = reader.GetString(reader.GetOrdinal("docMimeType")); + var content = (byte[])reader["docContent"]; + + return File(content, mimeType, fileName); + } + catch (Exception ex) + { + _log.LogError(ex, "Client document download failed: docId={DocId}", id); + return StatusCode(500, new { ok = false, error = "Download failed" }); + } + } + + // ── DELETE /api/documents/{id} ─────────────────────────────────────────── + [HttpDelete("{id:long}")] + public async Task Delete(long id, CancellationToken ct = default) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + try + { + _log.LogInformation("[ClientDocs] Delete docId={DocId} | Client={ClientId}", id, _client.ClientId); + + // Pass clientId so the SP enforces ownership before deleting + var rqst = JsonSerializer.Serialize(new { docId = id, clientId = _client.ClientId }); + var result = await _sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct); + return Content(result, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Client document delete failed: docId={DocId}", id); + return StatusCode(500, new { ok = false, error = "Delete failed" }); + } + } + + // ─── Upload helper: binary passed separately from JSON rqst ────────────── + private async Task ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct) + { + var cs = _config.GetConnectionString("Sql") + ?? throw new InvalidOperationException("Missing ConnectionStrings:Sql"); + + await using var conn = new SqlConnection(cs); + await conn.OpenAsync(ct); + + await using var cmd = new SqlCommand("dbo.usp_Document", conn) + { + CommandType = CommandType.StoredProcedure, + CommandTimeout = 60 + }; + + cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" }); + cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst }); + cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent }); + + var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1) + { + Direction = ParameterDirection.Output + }; + cmd.Parameters.Add(pResp); + + await cmd.ExecuteNonQueryAsync(ct); + + return pResp.Value as string + ?? JsonSerializer.Serialize(new { ok = false, error = "No response from database" }); + } +} diff --git a/Gateway/Controllers/DemographicsController.cs b/Gateway/Controllers/DemographicsController.cs new file mode 100644 index 0000000..7c053d3 --- /dev/null +++ b/Gateway/Controllers/DemographicsController.cs @@ -0,0 +1,179 @@ +using Gateway.Data; +using Gateway.Security; +using Gateway.Services; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Gateway.Controllers; + +/// +/// Census demographic data endpoints for the campaign wizard. +/// +/// GET /api/demographics/{zcta} — fetch census data from DB, forward to +/// Intelligence container for derived +/// recommendations. Falls back to raw census +/// data if Intelligence is unreachable. +/// POST /api/demographics/list — multiple ZCTAs (raw data only) +/// POST /api/demographics/search — find ZCTAs by criteria (raw data only) +/// +[ApiController] +[Route("api/demographics")] +public sealed class DemographicsController : ControllerBase +{ + private readonly SqlService _sql; + private readonly AuthorizationGuard _guard; + private readonly IntelligenceApiClient _intelligence; + private readonly ILogger _log; + + public DemographicsController( + SqlService sql, + AuthorizationGuard guard, + IntelligenceApiClient intelligence, + ILogger log) + { + _sql = sql; + _guard = guard; + _intelligence = intelligence; + _log = log; + } + + /// + /// Fetch raw census data for a ZCTA, then forward to Intelligence container + /// for derived audience recommendations (age chips, income tiers, insights). + /// Falls back to raw census data only if Intelligence is unreachable. + /// + [HttpGet("{zcta}")] + public async Task Get(string zcta, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + if (string.IsNullOrWhiteSpace(zcta) || zcta.Length != 5 || !zcta.All(char.IsDigit)) + return BadRequest(new { ok = false, error = "Valid 5-digit ZIP code is required" }); + + try + { + // 1. Fetch raw census data from DB + var censusJson = await _sql.ExecProcAsync( + SqlNames.Procs.Demographics, "get", + JsonSerializer.Serialize(new { zcta }), + ct: ct); + + if (string.IsNullOrWhiteSpace(censusJson)) + return NotFound(new { ok = false, error = "ZIP code not found" }); + + using var doc = JsonDocument.Parse(censusJson); + var censusRoot = doc.RootElement; + + if (censusRoot.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + return NotFound(new { ok = false, error = "ZIP code not found" }); + + // 2. Forward to Intelligence container for market analysis derivation + var analysis = await _intelligence.GetDemographicAnalysisAsync(zcta, censusRoot, ct); + + if (analysis != null) + { + _log.LogInformation("[Demographics] Analysis by Intelligence container | ZCTA={Zcta}", zcta); + return Content(analysis, "application/json"); + } + + // 3. Fallback: return raw census data + _log.LogInformation("[Demographics] Intelligence unavailable — raw census | ZCTA={Zcta}", zcta); + return Content(censusJson, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Demographics get error for ZCTA {Zcta}", zcta); + return StatusCode(500, new { ok = false, error = "Demographics service error" }); + } + } + + /// Get demographics for multiple ZCTAs (raw census data). + [HttpPost("list")] + public async Task List([FromBody] ZctaListRequest? request, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + if (request?.Zctas == null || request.Zctas.Length == 0) + return BadRequest(new { ok = false, error = "zctas array is required" }); + + try + { + var rqst = JsonSerializer.Serialize(new + { + zctas = request.Zctas, + page = request.Page ?? 1, + pageSize = request.PageSize ?? 50 + }); + + var resp = await _sql.ExecProcAsync(SqlNames.Procs.Demographics, "list", rqst, ct: ct); + if (string.IsNullOrWhiteSpace(resp)) + return StatusCode(500, new { ok = false, error = "Demographics service unavailable" }); + + return Content(resp, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Demographics list error"); + return StatusCode(500, new { ok = false, error = "Demographics service error" }); + } + } + + /// Search ZCTAs by demographic criteria (raw census data). + [HttpPost("search")] + public async Task Search([FromBody] DemographicSearchRequest? request, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + try + { + var rqst = JsonSerializer.Serialize(new + { + zctaPrefix = request?.ZctaPrefix, + minIncome = request?.MinIncome, + maxIncome = request?.MaxIncome, + minPopulation = request?.MinPopulation, + minBachelorPct = request?.MinBachelorPct, + minAge25to34Pct = request?.MinAge25to34Pct, + minHomeValue = request?.MinHomeValue, + page = request?.Page ?? 1, + pageSize = request?.PageSize ?? 50 + }); + + var resp = await _sql.ExecProcAsync(SqlNames.Procs.Demographics, "search", rqst, ct: ct); + if (string.IsNullOrWhiteSpace(resp)) + return StatusCode(500, new { ok = false, error = "Demographics service unavailable" }); + + return Content(resp, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Demographics search error"); + return StatusCode(500, new { ok = false, error = "Demographics service error" }); + } + } +} + +// ── DTOs ── + +public sealed class ZctaListRequest +{ + public string[]? Zctas { get; set; } + public int? Page { get; set; } + public int? PageSize { get; set; } +} + +public sealed class DemographicSearchRequest +{ + public string? ZctaPrefix { get; set; } + public int? MinIncome { get; set; } + public int? MaxIncome { get; set; } + public int? MinPopulation { get; set; } + public decimal? MinBachelorPct { get; set; } + public decimal? MinAge25to34Pct { get; set; } + public int? MinHomeValue { get; set; } + public int? Page { get; set; } + public int? PageSize { get; set; } +} diff --git a/Gateway/Controllers/ExecutionController.cs b/Gateway/Controllers/ExecutionController.cs index 7e3e914..14586ae 100644 --- a/Gateway/Controllers/ExecutionController.cs +++ b/Gateway/Controllers/ExecutionController.cs @@ -1,3 +1,4 @@ +using Gateway.Security; using Gateway.Services; using Microsoft.AspNetCore.Mvc; using System.Text.Json; @@ -9,17 +10,25 @@ namespace Gateway.Controllers; public sealed class ExecutionController : ControllerBase { private readonly ExecutionService _svc; - public ExecutionController(ExecutionService svc) => _svc = svc; + private readonly ClientContext _client; + + public ExecutionController(ExecutionService svc, ClientContext client) + { + _svc = svc; + _client = client; + } [HttpPost("request")] public async Task Execute([FromBody] JsonElement body) { + // SECURITY: Require authenticated session + if (!_client.IsAuthenticated) + return Unauthorized(new { ok = false, error = "Authentication required" }); + if (body.ValueKind == JsonValueKind.Undefined || body.ValueKind == JsonValueKind.Null) return BadRequest(new { ok = false, error = "Missing request body" }); var resp = await _svc.ExecuteAsync(body, HttpContext.RequestAborted); - - // resp is JsonElement / JsonDocument / string json you decide. return Content(resp, "application/json"); } } diff --git a/Gateway/Controllers/ForecastController.cs b/Gateway/Controllers/ForecastController.cs new file mode 100644 index 0000000..61e42ba --- /dev/null +++ b/Gateway/Controllers/ForecastController.cs @@ -0,0 +1,96 @@ +using Gateway.Models; +using Gateway.Security; +using Gateway.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Gateway.Controllers; + +/// +/// Channel forecast endpoint for the campaign wizard. +/// +/// Routes to IntelligenceApi (category-aware engine container) when configured. +/// Falls back to local ForecastService (General/rules-based) if IntelligenceApi +/// is unreachable — ensuring the wizard never breaks during deployments. +/// +/// ROUTING LOGIC: +/// 1. Try IntelligenceApi — passes clientCategory so the engine router +/// can select the correct model (General, Franchisee, Franchisor, etc.) +/// 2. If unreachable / error → fall back to local ForecastService +/// +/// SECURITY: Requires authenticated client session. +/// +[ApiController] +[Route("api/forecast")] +public sealed class ForecastController : ControllerBase +{ + private readonly ForecastService _forecastService; + private readonly IntelligenceApiClient _intelligenceClient; + private readonly ClientContext _client; + private readonly AuthorizationGuard _guard; + private readonly ILogger _log; + + public ForecastController( + ForecastService forecastService, + IntelligenceApiClient intelligenceClient, + ClientContext client, + AuthorizationGuard guard, + ILogger log) + { + _forecastService = forecastService; + _intelligenceClient = intelligenceClient; + _client = client; + _guard = guard; + _log = log; + } + + /// + /// Generate channel performance estimates for given targeting + budget. + /// Called by the wizard AllocationStep when budget changes. + /// + /// POST /api/forecast/channel-estimate + /// + [HttpPost("channel-estimate")] + public async Task ChannelEstimate( + [FromBody] ChannelForecastRequest? request, + CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + if (request == null) + return BadRequest(new { ok = false, error = "Request body is required" }); + + if (request.MonthlyBudget <= 0) + return BadRequest(new { ok = false, error = "monthlyBudget must be greater than zero" }); + + if (request.Keywords.Count == 0) + return BadRequest(new { ok = false, error = "At least one keyword is required" }); + + _log.LogInformation( + "[Forecast] Request | Category={Category} Budget={Budget} Objective={Obj}", + _client.ClientCategory, request.MonthlyBudget, request.Objective); + + try + { + // ── 1. Try IntelligenceApi (category-aware) ── + var result = await _intelligenceClient.GetSpendDistributionAsync( + request, _client.ClientCategory, ct); + + if (result != null) + { + _log.LogInformation("[Forecast] Served by IntelligenceApi"); + return Ok(result); + } + + // ── 2. Fallback: local ForecastService (General engine equivalent) ── + _log.LogInformation("[Forecast] IntelligenceApi unavailable — using local ForecastService"); + var fallback = await _forecastService.ForecastAsync(request, ct); + return Ok(fallback); + } + catch (Exception ex) + { + _log.LogError(ex, "[Forecast] Error"); + return StatusCode(500, new { ok = false, error = "Forecast service error" }); + } + } +} diff --git a/Gateway/Controllers/InitiativeController.cs b/Gateway/Controllers/InitiativeController.cs new file mode 100644 index 0000000..97a0935 --- /dev/null +++ b/Gateway/Controllers/InitiativeController.cs @@ -0,0 +1,502 @@ +using Gateway.Data; +using Gateway.Models; +using Gateway.Security; +using Gateway.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace Gateway.Controllers; + +/// +/// Multi-channel initiative endpoints. +/// +/// SECURITY MODEL: +/// - Every endpoint requires authenticated session (via middleware) +/// - Every resource-specific endpoint validates ownership (initiative → client) +/// - Status changes are restricted to valid client-initiated transitions +/// - Sync endpoint is restricted to admin role +/// - Budget values are validated server-side against channel minimums +/// - ClientId is injected server-side, never trusted from request body +/// +[ApiController] +[Route("api/initiative")] +public sealed class InitiativeController : ControllerBase +{ + private readonly SqlService _sql; + private readonly ClientContext _client; + private readonly AuthorizationGuard _guard; + private readonly MultiChannelConfig _config; + private readonly InitiativeLaunchService _launch; + private readonly ProviderStatusNormalizer _statusNorm; + private readonly ILogger _log; + + public InitiativeController( + SqlService sql, + ClientContext client, + AuthorizationGuard guard, + IOptions config, + InitiativeLaunchService launch, + ProviderStatusNormalizer statusNorm, + ILogger log) + { + _sql = sql; + _client = client; + _guard = guard; + _config = config.Value; + _launch = launch; + _statusNorm = statusNorm; + _log = log; + } + + // ──────────────────────────────────────────────── + // Initiative CRUD + // ──────────────────────────────────────────────── + + /// Create a new initiative with channel allocations. + [HttpPost] + public async Task Create([FromBody] CreateInitiativeRequest request, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + if (request.TotalBudget.HasValue) + { + var (budgetOk, budgetErr) = _guard.ValidateBudget( + request.TotalBudget.Value, request.BudgetPeriod, _config); + if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr }); + } + + var rqst = JsonSerializer.Serialize(new + { + clientId = _client.ClientId, + userId = _client.UserId, + name = request.Name, + objective = request.Objective, + totalBudget = request.TotalBudget, + budgetPeriod = request.BudgetPeriod ?? "monthly", + startDate = request.StartDate, + endDate = request.EndDate, + allocationStrategy = request.AllocationStrategy ?? "manual", + businessCategory = request.BusinessCategory, + wizardId = request.WizardId, + channels = request.Channels + }); + + return await Exec(SqlNames.Procs.Initiative, "create", rqst, ct); + } + + /// + /// Stage an initiative for confirmation with server-calculated billing. + /// + [HttpPost("stage")] + public async Task Stage([FromBody] CreateInitiativeRequest request, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + if (request.InitiativeId.HasValue && request.InitiativeId > 0) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(request.InitiativeId.Value, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + } + + if (request.TotalBudget.HasValue) + { + var (budgetOk, budgetErr) = _guard.ValidateBudget( + request.TotalBudget.Value, request.BudgetPeriod, _config); + if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr }); + } + + var rqst = JsonSerializer.Serialize(new + { + clientId = _client.ClientId, + userId = _client.UserId, + name = request.Name, + objective = request.Objective, + totalBudget = request.TotalBudget, + budgetPeriod = request.BudgetPeriod ?? "monthly", + startDate = request.StartDate, + endDate = request.EndDate, + allocationStrategy = request.AllocationStrategy ?? "manual", + businessCategory = request.BusinessCategory, + wizardId = request.WizardId, + initiativeId = request.InitiativeId, + channels = request.Channels + }); + + return await Exec(SqlNames.Procs.InitiativeStage, "stage", rqst, ct); + } + + /// Get billing for a staged initiative. + [HttpGet("{initiativeId:long}/billing")] + public async Task GetBilling(long initiativeId, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + return await Exec(SqlNames.Procs.InitiativeStage, "getBilling", + JsonSerializer.Serialize(new { initiativeId }), ct); + } + + /// Get initiative by ID (ownership verified). + [HttpGet("{initiativeId:long}")] + public async Task Get(long initiativeId, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + if (!string.IsNullOrWhiteSpace(ownership.EntityJson)) + return Content(ownership.EntityJson, "application/json"); + + return await Exec(SqlNames.Procs.Initiative, "get", + JsonSerializer.Serialize(new { initiativeId }), ct); + } + + /// List initiatives for current client (always scoped). + [HttpGet] + public async Task List([FromQuery] string? status, [FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + return await Exec(SqlNames.Procs.Initiative, "list", + JsonSerializer.Serialize(new { clientId = _client.ClientId, status, page, pageSize }), ct); + } + + /// Update initiative metadata (ownership verified, status stripped). + [HttpPut("{initiativeId:long}")] + public async Task Update(long initiativeId, [FromBody] UpdateInitiativeRequest request, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + if (request.TotalBudget.HasValue) + { + var (budgetOk, budgetErr) = _guard.ValidateBudget(request.TotalBudget.Value, null, _config); + if (!budgetOk) return BadRequest(new { ok = false, error = budgetErr }); + } + + return await Exec(SqlNames.Procs.Initiative, "update", + JsonSerializer.Serialize(new + { + initiativeId, + clientId = _client.ClientId, + name = request.Name, + totalBudget = request.TotalBudget, + startDate = request.StartDate, + endDate = request.EndDate, + businessCategory = request.BusinessCategory + }), ct); + } + + /// + /// Update status with transition enforcement. + /// Clients: active↔paused, *→cancelled only. Admins: any transition. + /// + [HttpPatch("{initiativeId:long}/status")] + public async Task UpdateStatus(long initiativeId, [FromBody] UpdateInitiativeStatusRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request?.Status)) + return BadRequest(new { ok = false, error = "status is required" }); + + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + var isAdmin = string.Equals(_client.Role, "admin", StringComparison.OrdinalIgnoreCase); + if (!isAdmin) + { + var (transOk, transErr) = _guard.ValidateClientStatusTransition( + ownership.CurrentStatus, request.Status, "initiative"); + if (!transOk) return BadRequest(new { ok = false, error = transErr }); + } + + return await Exec(SqlNames.Procs.Initiative, "updateStatus", + JsonSerializer.Serialize(new { initiativeId, clientId = _client.ClientId, status = request.Status }), ct); + } + + /// Soft-delete (cannot delete active — cancel first). + [HttpDelete("{initiativeId:long}")] + public async Task Delete(long initiativeId, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + if (ownership.CurrentStatus == "active") + return BadRequest(new { ok = false, error = "Cannot delete an active initiative. Cancel it first." }); + + return await Exec(SqlNames.Procs.Initiative, "delete", + JsonSerializer.Serialize(new { initiativeId, clientId = _client.ClientId }), ct); + } + + // ──────────────────────────────────────────────── + // Launch / Dispatch + // ──────────────────────────────────────────────── + + /// Launch a staged initiative (ownership + status verified). + [HttpPost("{initiativeId:long}/launch")] + public async Task Launch(long initiativeId, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + if (ownership.CurrentStatus != "staged") + return BadRequest(new { ok = false, error = $"Initiative must be staged before launching (current: {ownership.CurrentStatus})" }); + + _log.LogInformation("[Initiative] Launch {InitiativeId} by {UserId}", initiativeId, _client.UserId); + + var result = await _launch.LaunchAsync(initiativeId, _client.ClientId ?? "", _client.UserId, ct); + + if (!result.Ok && result.Error != null) + { + _log.LogWarning("[Initiative] Launch failed {InitiativeId}: {Error}", initiativeId, result.Error); + return BadRequest(result); + } + + return Ok(result); + } + + // ──────────────────────────────────────────────── + // Channel Campaigns (all ownership-verified) + // ──────────────────────────────────────────────── + + [HttpGet("{initiativeId:long}/channels")] + public async Task ListChannels(long initiativeId, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error }); + return await Exec(SqlNames.Procs.ChannelCampaign, "list", JsonSerializer.Serialize(new { initiativeId }), ct); + } + + [HttpGet("channel/{channelCampaignId:long}")] + public async Task GetChannel(long channelCampaignId, CancellationToken ct) + { + var ownership = await _guard.VerifyChannelOwnerAsync(channelCampaignId, ct); + if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error }); + return await Exec(SqlNames.Procs.ChannelCampaign, "get", JsonSerializer.Serialize(new { channelCampaignId }), ct); + } + + /// Sync channel status — called by provider containers. + [HttpPatch("channel/{channelCampaignId:long}/sync")] + public async Task SyncChannel(long channelCampaignId, [FromBody] SyncChannelRequest request, CancellationToken ct) + { + var (ok, err) = _guard.RequireServiceKey(); + if (!ok) return StatusCode(403, new { ok = false, error = err }); + + var normalizedStatus = _statusNorm.Resolve(request.ChannelType, request.Status, request.ProviderStatus); + + _log.LogInformation("[Sync] Channel {Id} | {Provider} → {Status} | By={User}", + channelCampaignId, request.ProviderStatus, normalizedStatus, _client.UserId); + + return await Exec(SqlNames.Procs.ChannelCampaign, "sync", + JsonSerializer.Serialize(new + { + channelCampaignId, + externalCampaignId = request.ExternalCampaignId, + externalAccountId = request.ExternalAccountId, + status = normalizedStatus, + providerStatus = request.ProviderStatus + }), ct); + } + + // ──────────────────────────────────────────────── + // Budget Allocation (all ownership-verified) + // ──────────────────────────────────────────────── + + [HttpGet("{initiativeId:long}/allocation")] + public async Task GetAllocation(long initiativeId, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error }); + return await Exec(SqlNames.Procs.Allocation, "get", JsonSerializer.Serialize(new { initiativeId }), ct); + } + + [HttpPut("{initiativeId:long}/allocation")] + public async Task UpdateAllocation(long initiativeId, [FromBody] UpdateAllocationRequest request, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error }); + return await Exec(SqlNames.Procs.Allocation, "update", + JsonSerializer.Serialize(new { initiativeId, userId = _client.UserId, allocations = request.Allocations, reason = request.Reason }), ct); + } + + [HttpGet("{initiativeId:long}/allocation/recommend")] + public async Task GetRecommendation(long initiativeId, [FromQuery] string? businessCategory, [FromQuery] string? objective, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error }); + return await Exec(SqlNames.Procs.Allocation, "recommend", JsonSerializer.Serialize(new { initiativeId, businessCategory, objective }), ct); + } + + [HttpPost("{initiativeId:long}/allocation/apply")] + public async Task ApplyAllocation(long initiativeId, [FromBody] ApplyAllocationRequest request, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error }); + return await Exec(SqlNames.Procs.Allocation, "apply", + JsonSerializer.Serialize(new { initiativeId, source = request.Source, allocations = request.Allocations, reason = request.Reason }), ct); + } + + [HttpGet("{initiativeId:long}/allocation/history")] + public async Task GetAllocationHistory(long initiativeId, [FromQuery] int? limit, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error }); + return await Exec(SqlNames.Procs.Allocation, "history", JsonSerializer.Serialize(new { initiativeId, limit }), ct); + } + + // ──────────────────────────────────────────────── + // Channel Config (read-only) + // ──────────────────────────────────────────────── + + [HttpGet("channels/available")] + public async Task GetAvailableChannels(CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + var mappingsResp = await _sql.ExecProcAsync(SqlNames.Procs.ObjectiveMapping, "list", "{}", ct: ct); + + return Ok(new + { + ok = true, + channels = _config.EnabledChannels.Select(c => new + { + c.ChannelType, c.DisplayName, c.Description, c.Icon, c.Color, + c.MinDailyBudget, c.MinMonthlyBudget, c.SupportedObjectives, + c.SupportedCreativeFormats, c.ApprovalEstimateHours, c.IsStub + }), + allocation = new + { + _config.Allocation.MinMultiChannelMonthlyBudget, + _config.Allocation.MaxChannelsPerInitiative, + _config.Allocation.DefaultAllocationStrategy, + _config.Allocation.MinChannelAllocationPct, + _config.Allocation.MaxChannelAllocationPct + }, + objectiveMappings = JsonSerializer.Deserialize(mappingsResp ?? "{}") + }); + } + + /// Status mappings — available to authenticated clients. + [HttpGet("channels/status-mappings")] + public IActionResult GetStatusMappings([FromQuery] string? channelType) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + if (!string.IsNullOrWhiteSpace(channelType)) + return Ok(new { ok = true, channelType, mappings = _statusNorm.GetMappings(channelType) }); + + return Ok(new + { + ok = true, + channels = _config.Channels.Values.Select(c => new { c.ChannelType, c.DisplayName, c.Enabled, mappings = _statusNorm.GetMappings(c.ChannelType) }) + }); + } + + [HttpGet("templates")] + public async Task GetTemplates([FromQuery] string? businessCategory, [FromQuery] string? objective, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + return await Exec(SqlNames.Procs.Allocation, "getTemplates", JsonSerializer.Serialize(new { businessCategory, objective }), ct); + } + + // ──────────────────────────────────────────────── + // Performance Metrics (ownership-verified) + // ──────────────────────────────────────────────── + + [HttpGet("{initiativeId:long}/metrics")] + public async Task MetricsSummary(long initiativeId, [FromQuery] string? fromDate, [FromQuery] string? toDate, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error }); + return await Exec(SqlNames.Procs.PerformanceMetric, "summary", JsonSerializer.Serialize(new { initiativeId, fromDate, toDate }), ct); + } + + [HttpGet("{initiativeId:long}/metrics/compare")] + public async Task MetricsCompare(long initiativeId, [FromQuery] int? lookbackDays, CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) return NotFound(new { ok = false, error = ownership.Error }); + return await Exec(SqlNames.Procs.PerformanceMetric, "compare", JsonSerializer.Serialize(new { initiativeId, lookbackDays }), ct); + } + + // ──────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────── + + private async Task Exec(string proc, string action, string rqst, CancellationToken ct) + { + try + { + var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct); + if (string.IsNullOrWhiteSpace(resp)) + return StatusCode(500, new { ok = false, error = "Service unavailable" }); + + using var doc = JsonDocument.Parse(resp); + var root = doc.RootElement; + + if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + { + var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error"; + if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true) + return NotFound(JsonSerializer.Deserialize(resp)); + return BadRequest(JsonSerializer.Deserialize(resp)); + } + + return Content(resp, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Initiative {Action} error", action); + return StatusCode(500, new { ok = false, error = "Service error" }); + } + } +} + +// ── DTOs ── + +public sealed class CreateInitiativeRequest +{ + public long? InitiativeId { get; set; } + public string? Name { get; set; } + public string? Objective { get; set; } + public decimal? TotalBudget { get; set; } + public string? BudgetPeriod { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } + public string? AllocationStrategy { get; set; } + public string? BusinessCategory { get; set; } + public string? WizardId { get; set; } + public object[]? Channels { get; set; } +} + +public sealed class UpdateInitiativeRequest +{ + public string? Name { get; set; } + public decimal? TotalBudget { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } + public string? BusinessCategory { get; set; } +} + +public sealed class UpdateInitiativeStatusRequest { public string? Status { get; set; } } +public sealed class SyncChannelRequest +{ + public string? ExternalCampaignId { get; set; } + public string? ExternalAccountId { get; set; } + public string? Status { get; set; } + public string? ProviderStatus { get; set; } + public string? ChannelType { get; set; } +} +public sealed class UpdateAllocationRequest { public object[]? Allocations { get; set; } public string? Reason { get; set; } } +public sealed class ApplyAllocationRequest { public string? Source { get; set; } public object[]? Allocations { get; set; } public string? Reason { get; set; } } diff --git a/Gateway/Controllers/MetricSyncController.cs b/Gateway/Controllers/MetricSyncController.cs new file mode 100644 index 0000000..6866262 --- /dev/null +++ b/Gateway/Controllers/MetricSyncController.cs @@ -0,0 +1,62 @@ +using Gateway.Security; +using Gateway.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Gateway.Controllers; + +/// +/// Metric sync trigger — called by Management API or Azure Functions timer. +/// Pulls campaign performance data from provider containers and writes +/// it into the database, then triggers recommendation evaluation. +/// Secured by internal service key (X-Service-Key header). +/// +[ApiController] +[Route("api/sync")] +public sealed class MetricSyncController : ControllerBase +{ + private readonly MetricSyncService _sync; + private readonly AuthorizationGuard _guard; + private readonly ILogger _log; + + public MetricSyncController( + MetricSyncService sync, + AuthorizationGuard guard, + ILogger log) + { + _sync = sync; + _guard = guard; + _log = log; + } + + /// + /// Sync metrics for a specific client. + /// Pulls from all active channel campaign providers, writes to DB, + /// then triggers recommendation evaluation. + /// + [HttpPost("metrics/{clientId}")] + public async Task SyncClient( + string clientId, + [FromQuery] string? startDate, + [FromQuery] string? endDate, + CancellationToken ct) + { + var (ok, err) = _guard.RequireServiceKey(); + if (!ok) return StatusCode(403, new { ok = false, error = err }); + + _log.LogInformation("[MetricSync] Manual sync triggered for client {ClientId}", clientId); + + var result = await _sync.SyncClientMetricsAsync(clientId, startDate, endDate, ct); + + return Ok(new + { + ok = result.Success, + clientId = result.ClientId, + campaignsProcessed = result.CampaignsProcessed, + metricsWritten = result.MetricsWritten, + recommendationsGenerated = result.RecommendationsGenerated, + skipped = result.Skipped, + errors = result.Errors, + error = result.Error + }); + } +} diff --git a/Gateway/Controllers/RecommendationController.cs b/Gateway/Controllers/RecommendationController.cs new file mode 100644 index 0000000..3d55891 --- /dev/null +++ b/Gateway/Controllers/RecommendationController.cs @@ -0,0 +1,153 @@ +using Gateway.Data; +using Gateway.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Gateway.Controllers; + +/// +/// Client-facing recommendation endpoints. +/// +/// Clients can view, dismiss, and resolve recommendations for their +/// own campaigns. All endpoints are scoped to the authenticated client. +/// +/// Admin operations (rule CRUD, evaluate, cleanup) live in the +/// Management API at /api/admin/recommendations. +/// +[ApiController] +[Route("api/recommendations")] +public sealed class RecommendationController : ControllerBase +{ + private readonly SqlService _sql; + private readonly ClientContext _client; + private readonly AuthorizationGuard _guard; + private readonly ILogger _log; + + public RecommendationController( + SqlService sql, + ClientContext client, + AuthorizationGuard guard, + ILogger log) + { + _sql = sql; + _client = client; + _guard = guard; + _log = log; + } + + // ──────────────────────────────────────────────── + // Client-Facing: List Recommendations + // ──────────────────────────────────────────────── + + /// + /// Get active recommendations for the authenticated client's dashboard. + /// Returns recommendations sorted by severity (critical first). + /// + [HttpGet] + public async Task ListByClient( + [FromQuery] string? status, + [FromQuery] int? limit, + CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + return await Exec(SqlNames.Procs.Recommendation, "listByClient", + JsonSerializer.Serialize(new + { + clientId = _client.ClientId, + status = status ?? "active", + limit = limit ?? 50 + }), ct); + } + + /// + /// Get recommendations for a specific initiative (ownership verified). + /// + [HttpGet("initiative/{initiativeId:long}")] + public async Task ListByInitiative( + long initiativeId, + [FromQuery] string? status, + CancellationToken ct) + { + var ownership = await _guard.VerifyInitiativeOwnerAsync(initiativeId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + return await Exec(SqlNames.Procs.Recommendation, "listByInitiative", + JsonSerializer.Serialize(new + { + initiativeId, + status = status ?? "active" + }), ct); + } + + // ──────────────────────────────────────────────── + // Client-Facing: Manage Recommendations + // ──────────────────────────────────────────────── + + /// + /// Dismiss a recommendation (user explicitly ignores it). + /// + [HttpPost("{recommendationId:long}/dismiss")] + public async Task Dismiss(long recommendationId, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + // Ownership check: verify the recommendation belongs to this client + // The SP itself filters by recId, but we pass userId for audit trail + return await Exec(SqlNames.Procs.Recommendation, "dismiss", + JsonSerializer.Serialize(new + { + recommendationId, + userId = _client.UserId + }), ct); + } + + /// + /// Resolve a recommendation (action was taken to address it). + /// + [HttpPost("{recommendationId:long}/resolve")] + public async Task Resolve(long recommendationId, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + return await Exec(SqlNames.Procs.Recommendation, "resolve", + JsonSerializer.Serialize(new { recommendationId }), ct); + } + + // ──────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────── + + private async Task Exec(string proc, string action, string rqst, CancellationToken ct) + { + try + { + var resp = await _sql.ExecProcAsync(proc, action, rqst, ct: ct); + if (string.IsNullOrWhiteSpace(resp)) + return StatusCode(500, new { ok = false, error = "Service unavailable" }); + + using var doc = JsonDocument.Parse(resp); + var root = doc.RootElement; + + if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + { + var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error"; + if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true) + return NotFound(JsonSerializer.Deserialize(resp)); + return BadRequest(JsonSerializer.Deserialize(resp)); + } + + return Content(resp, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Recommendation {Action} error", action); + return StatusCode(500, new { ok = false, error = "Service error" }); + } + } +} + diff --git a/Gateway/Controllers/WizardController.cs b/Gateway/Controllers/WizardController.cs new file mode 100644 index 0000000..df5bdd1 --- /dev/null +++ b/Gateway/Controllers/WizardController.cs @@ -0,0 +1,301 @@ +using Gateway.Data; +using Gateway.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Gateway.Controllers; + +/// +/// Campaign wizard endpoints. +/// +/// SECURITY: Every wizard operation validates ownership (wizard → client). +/// ClientId is always injected server-side. +/// +[ApiController] +[Route("api/wizard")] +public sealed class WizardController : ControllerBase +{ + private readonly SqlService _sql; + private readonly ClientContext _client; + private readonly AuthorizationGuard _guard; + private readonly ILogger _log; + + public WizardController(SqlService sql, ClientContext client, AuthorizationGuard guard, ILogger log) + { + _sql = sql; + _client = client; + _guard = guard; + _log = log; + } + + /// + /// Get active categories + objectives for wizard Step 1. + /// Client-authenticated (not admin). Read-only. + /// Calls spAdminTemplateConfig with action 'public.config'. + /// + [HttpGet("config")] + public async Task GetConfig(CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + try + { + var resp = await _sql.ExecProcAsync( + SqlNames.Procs.TemplateConfig, + "public.config", + "{}", + ct: ct + ); + + if (string.IsNullOrWhiteSpace(resp)) + return StatusCode(500, new { ok = false, error = "Config service unavailable" }); + + return Content(resp, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Wizard config error"); + return StatusCode(500, new { ok = false, error = "Config service error" }); + } + } + + /// Create a new wizard (no ownership check — creates for current client). + [HttpPost] + public async Task Create([FromBody] CreateWizardRequest? request, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + var rqst = JsonSerializer.Serialize(new + { + clientId = _client.ClientId, // ← SERVER-SIDE + userId = _client.UserId, + name = request?.Name, + url = request?.Url + }); + + return await ExecAndReturn("create", rqst, ct); + } + + /// Get wizard by ID (ownership verified). + [HttpGet("{wizardId}")] + public async Task Get(string wizardId, CancellationToken ct) + { + var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + // Return already-fetched entity + if (!string.IsNullOrWhiteSpace(ownership.EntityJson)) + return Content(ownership.EntityJson, "application/json"); + + var rqst = JsonSerializer.Serialize(new { wizardId }); + return await ExecAndReturn("get", rqst, ct); + } + + /// List wizards for current client (always scoped). + [HttpGet] + public async Task List([FromQuery] string? status, [FromQuery] int? limit, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + var rqst = JsonSerializer.Serialize(new + { + clientId = _client.ClientId, // ← scoped to authenticated client + status, + limit + }); + + return await ExecAndReturn("listByClient", rqst, ct); + } + + /// Update step data (ownership verified, steps 1-4 only). + [HttpPut("{wizardId}/step/{step:int}")] + public async Task UpdateStep(string wizardId, int step, [FromBody] UpdateStepRequest? request, CancellationToken ct) + { + if (step < 1 || step > 5) + return BadRequest(new { ok = false, error = "Step must be 1-5" }); + + var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + var rqst = JsonSerializer.Serialize(new + { + wizardId, + step, + data = request?.Data, + name = request?.Name + }); + + return await ExecAndReturn("updateStep", rqst, ct); + } + + /// Navigate to step (ownership verified). + [HttpPatch("{wizardId}/step/{step:int}")] + public async Task SetStep(string wizardId, int step, CancellationToken ct) + { + var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + var rqst = JsonSerializer.Serialize(new { wizardId, step }); + return await ExecAndReturn("setStep", rqst, ct); + } + + /// Get wizard summary for review (ownership verified). + [HttpGet("{wizardId}/summary")] + public async Task GetSummary(string wizardId, CancellationToken ct) + { + var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + var rqst = JsonSerializer.Serialize(new { wizardId }); + return await ExecAndReturn("getSummary", rqst, ct); + } + + /// Submit wizard (ownership verified). + [HttpPost("{wizardId}/submit")] + public async Task Submit(string wizardId, [FromBody] SubmitWizardRequest? request, CancellationToken ct) + { + var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + var rqst = JsonSerializer.Serialize(new + { + wizardId, + campaignId = (string?)null, + network = request?.Network ?? "google" + }); + + return await ExecAndReturn("submit", rqst, ct); + } + + /// Update wizard status (ownership verified, transition rules applied). + [HttpPatch("{wizardId}/status")] + public async Task UpdateStatus(string wizardId, [FromBody] UpdateStatusRequest? request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request?.Status)) + return BadRequest(new { ok = false, error = "status is required" }); + + var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + // Wizard status transitions: only allow cancel from draft + var current = ownership.CurrentStatus ?? "draft"; + var requested = request.Status.ToLowerInvariant(); + var isAdmin = string.Equals(_client.Role, "admin", StringComparison.OrdinalIgnoreCase); + + if (!isAdmin && requested != "cancelled") + return BadRequest(new { ok = false, error = $"Cannot change wizard status to '{requested}'" }); + + var rqst = JsonSerializer.Serialize(new { wizardId, status = request.Status }); + return await ExecAndReturn("updateStatus", rqst, ct); + } + + /// Delete wizard (ownership verified). + [HttpDelete("{wizardId}")] + public async Task Delete(string wizardId, [FromQuery] bool force = false, CancellationToken ct = default) + { + var ownership = await _guard.VerifyWizardOwnerAsync(wizardId, ct); + if (!ownership.IsAllowed) + return NotFound(new { ok = false, error = ownership.Error }); + + var rqst = JsonSerializer.Serialize(new { wizardId, force }); + return await ExecAndReturn("delete", rqst, ct); + } + + /// + /// Get audience-adjusted channel mix recommendation. + /// Calls spAllocationRecommend with audience factors. + /// + [HttpPost("recommend")] + public async Task Recommend([FromBody] RecommendRequest? request, CancellationToken ct) + { + var (ok, err) = _guard.RequireAuth(); + if (!ok) return Unauthorized(new { ok = false, error = err }); + + if (string.IsNullOrWhiteSpace(request?.BusinessCategory) || string.IsNullOrWhiteSpace(request?.Objective)) + return BadRequest(new { ok = false, error = "businessCategory and objective are required" }); + + try + { + var rqst = JsonSerializer.Serialize(new + { + businessCategory = request.BusinessCategory, + objective = request.Objective, + ageSkew = request.AgeSkew, + marketScope = request.MarketScope + }); + + var resp = await _sql.ExecProcAsync( + SqlNames.Procs.AllocationRecommend, + "recommend", + rqst, + ct: ct + ); + + if (string.IsNullOrWhiteSpace(resp)) + return StatusCode(500, new { ok = false, error = "Recommendation service unavailable" }); + + return Content(resp, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Recommend error"); + return StatusCode(500, new { ok = false, error = "Recommendation service error" }); + } + } + + // ──────────────────────────────────────────────── + // Helper + // ──────────────────────────────────────────────── + + private async Task ExecAndReturn(string action, string rqst, CancellationToken ct) + { + try + { + var resp = await _sql.ExecProcAsync("dbo.spCampaignWizard", action, rqst, ct: ct); + + if (string.IsNullOrWhiteSpace(resp)) + return StatusCode(500, new { ok = false, error = "Wizard service unavailable" }); + + using var doc = JsonDocument.Parse(resp); + var root = doc.RootElement; + + if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + { + var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Unknown error"; + if (error?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true) + return NotFound(JsonSerializer.Deserialize(resp)); + return BadRequest(JsonSerializer.Deserialize(resp)); + } + + return Content(resp, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "Wizard {Action} error", action); + return StatusCode(500, new { ok = false, error = "Wizard service error" }); + } + } +} + +// ── DTOs ── + +public sealed class CreateWizardRequest { public string? Name { get; set; } public string? Url { get; set; } } +public sealed class UpdateStepRequest { public object? Data { get; set; } public string? Name { get; set; } } +public sealed class SubmitWizardRequest { public string? Network { get; set; } } +public sealed class UpdateStatusRequest { public string? Status { get; set; } } +public sealed class RecommendRequest +{ + public string? BusinessCategory { get; set; } + public string? Objective { get; set; } + public string? AgeSkew { get; set; } + public string? MarketScope { get; set; } +} \ No newline at end of file diff --git a/Gateway/Data/SqlNames.cs b/Gateway/Data/SqlNames.cs index 20a3fc1..23ffb19 100644 --- a/Gateway/Data/SqlNames.cs +++ b/Gateway/Data/SqlNames.cs @@ -1,14 +1,36 @@ -namespace Gateway.Data; +namespace Gateway.Data; public static class SqlNames { public static class Procs { - public const string Client = "dbo.spClient"; - public const string User = "dbo.spUser"; + // ── Existing ── + public const string Client = "dbo.spClient"; + public const string User = "dbo.spUser"; public const string UserClientRole = "dbo.spUserClientRole"; - public const string AdAccount = "dbo.spAdAccount"; - public const string AdCampaign = "dbo.spAdCampaign"; - public const string Invoice = "dbo.spInvoice"; + public const string AdAccount = "dbo.spAdAccount"; + public const string AdCampaign = "dbo.spAdCampaign"; + public const string Invoice = "dbo.spInvoice"; + + // ── Multi-Channel ── + public const string Initiative = "dbo.spInitiative"; + public const string ChannelCampaign = "dbo.spChannelCampaign"; + public const string ChannelConfig = "dbo.spChannelConfig"; + public const string InitiativeStage = "dbo.spInitiativeStage"; + public const string Allocation = "dbo.spAllocation"; + public const string ObjectiveMapping = "dbo.spObjectiveMapping"; + public const string PerformanceMetric = "dbo.spPerformanceMetric"; + + // ── Campaign Wizard ── + public const string CampaignWizard = "dbo.spCampaignWizard"; + public const string TemplateConfig = "dbo.spAdminTemplateConfig"; + public const string AllocationRecommend = "dbo.spAllocationRecommend"; + + // ── Campaign Intelligence ── + public const string CampaignIntelligence = "dbo.spCampaignIntelligence"; + public const string Recommendation = "dbo.spRecommendation"; + + // ── Census Demographics ── + public const string Demographics = "dbo.spDemographics"; } } diff --git a/Gateway/Gateway.csproj b/Gateway/Gateway.csproj index 328b8be..d819648 100644 --- a/Gateway/Gateway.csproj +++ b/Gateway/Gateway.csproj @@ -13,12 +13,12 @@ + - - - - - + + + + diff --git a/Gateway/Migrations/001_ChannelConfig.sql b/Gateway/Migrations/001_ChannelConfig.sql new file mode 100644 index 0000000..03dac39 --- /dev/null +++ b/Gateway/Migrations/001_ChannelConfig.sql @@ -0,0 +1,321 @@ +-- ============================================================ +-- 001_ChannelConfig.sql +-- Move channel provider configuration from appsettings.json +-- into database-driven configuration. +-- ============================================================ + +-- ── Table ────────────────────────────────────────────────── + +IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'tbChannelConfig') +BEGIN + CREATE TABLE dbo.tbChannelConfig ( + chcChannelType VARCHAR(50) NOT NULL PRIMARY KEY, + chcDisplayName NVARCHAR(100) NOT NULL, + chcDescription NVARCHAR(500) NULL, + chcIcon VARCHAR(50) NULL, + chcColor VARCHAR(20) NULL, + chcEnabled BIT NOT NULL DEFAULT 1, + chcIsStub BIT NOT NULL DEFAULT 1, + chcEndpoint VARCHAR(500) NULL, + chcInternalKey VARCHAR(500) NULL, + chcMinDailyBudget DECIMAL(10,2) NOT NULL DEFAULT 5.00, + chcMinMonthlyBudget DECIMAL(10,2) NOT NULL DEFAULT 150.00, + chcSupportedObjectives NVARCHAR(500) NULL, -- JSON array: ["sales","leads","traffic"] + chcSupportedCreativeFormats NVARCHAR(500) NULL, -- JSON array: ["text","image","video"] + chcApprovalEstimateHours INT NOT NULL DEFAULT 24, + chcMetricsRefreshIntervalMinutes INT NOT NULL DEFAULT 60, + chcAuthMethod VARCHAR(50) NULL, + chcKeyVaultSecretName VARCHAR(200) NULL, + chcStatusMappings NVARCHAR(MAX) NULL, -- JSON object: {"ENABLED":"active",...} + chcSortOrder INT NOT NULL DEFAULT 0, + chcCreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), + chcUpdatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE() + ); + PRINT 'Created table tbChannelConfig'; +END +GO + +-- ── Stored Procedure ─────────────────────────────────────── + +CREATE OR ALTER PROCEDURE dbo.spChannelConfig + @action VARCHAR(50), + @rqst NVARCHAR(MAX) = '{}', + @resp NVARCHAR(MAX) OUTPUT +AS +BEGIN + SET NOCOUNT ON; + + -- ── list: return all enabled channels ── + IF @action = 'list' + BEGIN + SET @resp = ( + SELECT + chcChannelType AS channelType, + chcDisplayName AS displayName, + chcDescription AS [description], + chcIcon AS icon, + chcColor AS color, + chcEnabled AS [enabled], + chcIsStub AS isStub, + chcEndpoint AS endpoint, + chcInternalKey AS internalKey, + chcMinDailyBudget AS minDailyBudget, + chcMinMonthlyBudget AS minMonthlyBudget, + JSON_QUERY(chcSupportedObjectives) AS supportedObjectives, + JSON_QUERY(chcSupportedCreativeFormats) AS supportedCreativeFormats, + chcApprovalEstimateHours AS approvalEstimateHours, + chcMetricsRefreshIntervalMinutes AS metricsRefreshIntervalMinutes, + chcAuthMethod AS authMethod, + chcKeyVaultSecretName AS keyVaultSecretName, + JSON_QUERY(chcStatusMappings) AS statusMappings, + chcSortOrder AS sortOrder + FROM dbo.tbChannelConfig + ORDER BY chcSortOrder, chcChannelType + FOR JSON PATH + ); + + IF @resp IS NULL SET @resp = '[]'; + SET @resp = '{"ok":true,"channels":' + @resp + '}'; + RETURN; + END + + -- ── listAll: return all channels including disabled (for admin) ── + IF @action = 'listAll' + BEGIN + SET @resp = ( + SELECT + chcChannelType AS channelType, + chcDisplayName AS displayName, + chcDescription AS [description], + chcIcon AS icon, + chcColor AS color, + chcEnabled AS [enabled], + chcIsStub AS isStub, + chcEndpoint AS endpoint, + chcMinDailyBudget AS minDailyBudget, + chcMinMonthlyBudget AS minMonthlyBudget, + JSON_QUERY(chcSupportedObjectives) AS supportedObjectives, + JSON_QUERY(chcSupportedCreativeFormats) AS supportedCreativeFormats, + chcApprovalEstimateHours AS approvalEstimateHours, + chcMetricsRefreshIntervalMinutes AS metricsRefreshIntervalMinutes, + chcAuthMethod AS authMethod, + chcKeyVaultSecretName AS keyVaultSecretName, + JSON_QUERY(chcStatusMappings) AS statusMappings, + chcSortOrder AS sortOrder, + chcCreatedAt AS createdAt, + chcUpdatedAt AS updatedAt + FROM dbo.tbChannelConfig + ORDER BY chcSortOrder, chcChannelType + FOR JSON PATH + ); + + IF @resp IS NULL SET @resp = '[]'; + SET @resp = '{"ok":true,"channels":' + @resp + '}'; + RETURN; + END + + -- ── get: return single channel by type ── + IF @action = 'get' + BEGIN + DECLARE @channelType VARCHAR(50) = JSON_VALUE(@rqst, '$.channelType'); + + IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = @channelType) + BEGIN + SET @resp = '{"ok":false,"error":"Channel not found"}'; + RETURN; + END + + SET @resp = ( + SELECT + chcChannelType AS channelType, + chcDisplayName AS displayName, + chcDescription AS [description], + chcIcon AS icon, + chcColor AS color, + chcEnabled AS [enabled], + chcIsStub AS isStub, + chcEndpoint AS endpoint, + chcMinDailyBudget AS minDailyBudget, + chcMinMonthlyBudget AS minMonthlyBudget, + JSON_QUERY(chcSupportedObjectives) AS supportedObjectives, + JSON_QUERY(chcSupportedCreativeFormats) AS supportedCreativeFormats, + chcApprovalEstimateHours AS approvalEstimateHours, + chcMetricsRefreshIntervalMinutes AS metricsRefreshIntervalMinutes, + chcAuthMethod AS authMethod, + chcKeyVaultSecretName AS keyVaultSecretName, + JSON_QUERY(chcStatusMappings) AS statusMappings + FROM dbo.tbChannelConfig + WHERE chcChannelType = @channelType + FOR JSON PATH, WITHOUT_ARRAY_WRAPPER + ); + + SET @resp = '{"ok":true,"channel":' + @resp + '}'; + RETURN; + END + + -- ── upsert: create or update a channel (admin) ── + IF @action = 'upsert' + BEGIN + DECLARE @uChannelType VARCHAR(50) = JSON_VALUE(@rqst, '$.channelType'); + + IF @uChannelType IS NULL + BEGIN + SET @resp = '{"ok":false,"error":"channelType is required"}'; + RETURN; + END + + MERGE dbo.tbChannelConfig AS tgt + USING (SELECT @uChannelType AS chcChannelType) AS src + ON tgt.chcChannelType = src.chcChannelType + WHEN MATCHED THEN + UPDATE SET + chcDisplayName = ISNULL(JSON_VALUE(@rqst, '$.displayName'), tgt.chcDisplayName), + chcDescription = ISNULL(JSON_VALUE(@rqst, '$.description'), tgt.chcDescription), + chcIcon = ISNULL(JSON_VALUE(@rqst, '$.icon'), tgt.chcIcon), + chcColor = ISNULL(JSON_VALUE(@rqst, '$.color'), tgt.chcColor), + chcEnabled = ISNULL(CAST(JSON_VALUE(@rqst, '$.enabled') AS BIT), tgt.chcEnabled), + chcIsStub = ISNULL(CAST(JSON_VALUE(@rqst, '$.isStub') AS BIT), tgt.chcIsStub), + chcEndpoint = CASE WHEN JSON_VALUE(@rqst, '$.endpoint') IS NOT NULL + THEN JSON_VALUE(@rqst, '$.endpoint') + ELSE tgt.chcEndpoint END, + chcInternalKey = CASE WHEN JSON_VALUE(@rqst, '$.internalKey') IS NOT NULL + THEN JSON_VALUE(@rqst, '$.internalKey') + ELSE tgt.chcInternalKey END, + chcMinDailyBudget = ISNULL(CAST(JSON_VALUE(@rqst, '$.minDailyBudget') AS DECIMAL(10,2)), tgt.chcMinDailyBudget), + chcMinMonthlyBudget = ISNULL(CAST(JSON_VALUE(@rqst, '$.minMonthlyBudget') AS DECIMAL(10,2)), tgt.chcMinMonthlyBudget), + chcSupportedObjectives = CASE WHEN JSON_QUERY(@rqst, '$.supportedObjectives') IS NOT NULL + THEN JSON_QUERY(@rqst, '$.supportedObjectives') + ELSE tgt.chcSupportedObjectives END, + chcSupportedCreativeFormats = CASE WHEN JSON_QUERY(@rqst, '$.supportedCreativeFormats') IS NOT NULL + THEN JSON_QUERY(@rqst, '$.supportedCreativeFormats') + ELSE tgt.chcSupportedCreativeFormats END, + chcApprovalEstimateHours = ISNULL(CAST(JSON_VALUE(@rqst, '$.approvalEstimateHours') AS INT), tgt.chcApprovalEstimateHours), + chcMetricsRefreshIntervalMinutes = ISNULL(CAST(JSON_VALUE(@rqst, '$.metricsRefreshIntervalMinutes') AS INT), tgt.chcMetricsRefreshIntervalMinutes), + chcAuthMethod = ISNULL(JSON_VALUE(@rqst, '$.authMethod'), tgt.chcAuthMethod), + chcKeyVaultSecretName = ISNULL(JSON_VALUE(@rqst, '$.keyVaultSecretName'), tgt.chcKeyVaultSecretName), + chcStatusMappings = CASE WHEN JSON_QUERY(@rqst, '$.statusMappings') IS NOT NULL + THEN JSON_QUERY(@rqst, '$.statusMappings') + ELSE tgt.chcStatusMappings END, + chcSortOrder = ISNULL(CAST(JSON_VALUE(@rqst, '$.sortOrder') AS INT), tgt.chcSortOrder), + chcUpdatedAt = GETUTCDATE() + WHEN NOT MATCHED THEN + INSERT (chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor, + chcEnabled, chcIsStub, chcEndpoint, chcInternalKey, + chcMinDailyBudget, chcMinMonthlyBudget, + chcSupportedObjectives, chcSupportedCreativeFormats, + chcApprovalEstimateHours, chcMetricsRefreshIntervalMinutes, + chcAuthMethod, chcKeyVaultSecretName, chcStatusMappings, chcSortOrder) + VALUES ( + @uChannelType, + JSON_VALUE(@rqst, '$.displayName'), + JSON_VALUE(@rqst, '$.description'), + JSON_VALUE(@rqst, '$.icon'), + JSON_VALUE(@rqst, '$.color'), + ISNULL(CAST(JSON_VALUE(@rqst, '$.enabled') AS BIT), 1), + ISNULL(CAST(JSON_VALUE(@rqst, '$.isStub') AS BIT), 1), + JSON_VALUE(@rqst, '$.endpoint'), + JSON_VALUE(@rqst, '$.internalKey'), + ISNULL(CAST(JSON_VALUE(@rqst, '$.minDailyBudget') AS DECIMAL(10,2)), 5.00), + ISNULL(CAST(JSON_VALUE(@rqst, '$.minMonthlyBudget') AS DECIMAL(10,2)), 150.00), + JSON_QUERY(@rqst, '$.supportedObjectives'), + JSON_QUERY(@rqst, '$.supportedCreativeFormats'), + ISNULL(CAST(JSON_VALUE(@rqst, '$.approvalEstimateHours') AS INT), 24), + ISNULL(CAST(JSON_VALUE(@rqst, '$.metricsRefreshIntervalMinutes') AS INT), 60), + JSON_VALUE(@rqst, '$.authMethod'), + JSON_VALUE(@rqst, '$.keyVaultSecretName'), + JSON_QUERY(@rqst, '$.statusMappings'), + ISNULL(CAST(JSON_VALUE(@rqst, '$.sortOrder') AS INT), 0) + ); + + SET @resp = '{"ok":true}'; + RETURN; + END + + -- ── delete: remove a channel (admin) ── + IF @action = 'delete' + BEGIN + DECLARE @dChannelType VARCHAR(50) = JSON_VALUE(@rqst, '$.channelType'); + + DELETE FROM dbo.tbChannelConfig WHERE chcChannelType = @dChannelType; + + SET @resp = '{"ok":true,"deleted":' + CAST(@@ROWCOUNT AS VARCHAR) + '}'; + RETURN; + END + + SET @resp = '{"ok":false,"error":"Unknown action: ' + @action + '"}'; +END +GO + +-- ── Seed Data ────────────────────────────────────────────── + +-- Google Ads +IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = 'google_ads') +INSERT INTO dbo.tbChannelConfig ( + chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor, + chcEnabled, chcIsStub, chcEndpoint, + chcMinDailyBudget, chcMinMonthlyBudget, + chcSupportedObjectives, chcSupportedCreativeFormats, + chcApprovalEstimateHours, chcAuthMethod, chcKeyVaultSecretName, + chcStatusMappings, chcSortOrder +) VALUES ( + 'google_ads', + 'Google Ads', + 'Search, Display, Shopping & Performance Max across Google properties', + 'google', '#4285F4', + 1, 0, NULL, + 10.00, 300.00, + '["awareness","traffic","conversions","leads","sales"]', + '["text","image","responsive","video"]', + 24, 'mcc', 'google-ads-refresh-token', + '{"ENABLED":"active","Enabled":"active","PAUSED":"paused","Paused":"paused","REMOVED":"cancelled","Removed":"cancelled","UNKNOWN":"error","UNSPECIFIED":"error"}', + 1 +); + +-- Meta +IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = 'meta') +INSERT INTO dbo.tbChannelConfig ( + chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor, + chcEnabled, chcIsStub, chcEndpoint, + chcMinDailyBudget, chcMinMonthlyBudget, + chcSupportedObjectives, chcSupportedCreativeFormats, + chcApprovalEstimateHours, chcAuthMethod, chcKeyVaultSecretName, + chcStatusMappings, chcSortOrder +) VALUES ( + 'meta', + 'Meta Ads', + 'Facebook, Instagram, Messenger & Threads advertising', + 'meta', '#1877F2', + 1, 1, NULL, + 5.00, 250.00, + '["awareness","traffic","conversions","leads","sales"]', + '["image","video","carousel","stories"]', + 48, 'oauth2', 'meta-access-token', + '{"ACTIVE":"active","PAUSED":"paused","DELETED":"cancelled","ARCHIVED":"completed","IN_PROCESS":"pending","WITH_ISSUES":"error","CAMPAIGN_PAUSED":"paused","ADSET_PAUSED":"paused","DISAPPROVED":"error","PREAPPROVED":"pending","PENDING_REVIEW":"pending","PENDING_BILLING_INFO":"error"}', + 2 +); + +-- TikTok +IF NOT EXISTS (SELECT 1 FROM dbo.tbChannelConfig WHERE chcChannelType = 'tiktok') +INSERT INTO dbo.tbChannelConfig ( + chcChannelType, chcDisplayName, chcDescription, chcIcon, chcColor, + chcEnabled, chcIsStub, chcEndpoint, + chcMinDailyBudget, chcMinMonthlyBudget, + chcSupportedObjectives, chcSupportedCreativeFormats, + chcApprovalEstimateHours, chcAuthMethod, chcKeyVaultSecretName, + chcStatusMappings, chcSortOrder +) VALUES ( + 'tiktok', + 'TikTok Ads', + 'In-feed video ads across TikTok and partner apps', + 'tiktok', '#000000', + 1, 1, NULL, + 20.00, 200.00, + '["awareness","traffic","conversions","leads","sales"]', + '["video","image","spark_ads"]', + 24, 'oauth2', 'tiktok-access-token', + '{"ENABLE":"active","CAMPAIGN_STATUS_ENABLE":"active","DISABLE":"paused","CAMPAIGN_STATUS_DISABLE":"paused","DELETE":"cancelled","CAMPAIGN_STATUS_DELETE":"cancelled","BUDGET_EXCEED":"paused","CAMPAIGN_STATUS_BUDGET_EXCEED":"paused","ADVERTISER_AUDIT_DENY":"error","CAMPAIGN_STATUS_ADVERTISER_AUDIT_DENY":"error","NOT_DELETE":"active","ADVERTISER_AUDIT":"pending","CAMPAIGN_STATUS_ADVERTISER_AUDIT":"pending","REAUDIT":"pending","ALL":"active"}', + 3 +); + +PRINT 'Channel config seeded successfully'; +GO diff --git a/Gateway/Migrations/007_ProviderStatusMap.sql b/Gateway/Migrations/007_ProviderStatusMap.sql new file mode 100644 index 0000000..2a04ace --- /dev/null +++ b/Gateway/Migrations/007_ProviderStatusMap.sql @@ -0,0 +1,257 @@ +-- ============================================================ +-- Provider Status Mapping Reference Table +-- ============================================================ +-- Maps provider-specific campaign statuses to platform statuses. +-- Runtime normalization is config-driven (appsettings.json), +-- but this table serves as: +-- 1. Canonical reference / documentation +-- 2. Admin-editable override (future phase) +-- 3. Audit trail for mapping changes +-- +-- Platform statuses: draft, staged, pending, active, paused, +-- completed, cancelled, error +-- ============================================================ + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'tbProviderStatusMap') +BEGIN + CREATE TABLE dbo.tbProviderStatusMap ( + psmId INT IDENTITY(1,1) PRIMARY KEY, + psmChannelType VARCHAR(50) NOT NULL, -- google_ads, meta, tiktok + psmProviderStatus VARCHAR(100) NOT NULL, -- raw provider value (ENABLED, DELIVERY_OK, etc.) + psmPlatformStatus VARCHAR(20) NOT NULL, -- normalized platform value + psmDescription NVARCHAR(200) NULL, -- human-readable explanation + psmIsActive BIT NOT NULL DEFAULT 1, + psmCreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + psmUpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT UQ_ProviderStatusMap_Channel_Status + UNIQUE (psmChannelType, psmProviderStatus), + + CONSTRAINT CK_ProviderStatusMap_PlatformStatus + CHECK (psmPlatformStatus IN ('draft','staged','pending','active','paused','completed','cancelled','error')) + ); + + CREATE NONCLUSTERED INDEX IX_ProviderStatusMap_Channel + ON dbo.tbProviderStatusMap (psmChannelType) + INCLUDE (psmProviderStatus, psmPlatformStatus) + WHERE psmIsActive = 1; +END +GO + +-- ============================================================ +-- Seed: Google Ads +-- ============================================================ +MERGE dbo.tbProviderStatusMap AS tgt +USING (VALUES + ('google_ads', 'ENABLED', 'active', 'Campaign is serving ads'), + ('google_ads', 'Enabled', 'active', 'Campaign is serving ads (camelCase variant)'), + ('google_ads', 'PAUSED', 'paused', 'Campaign is paused by advertiser'), + ('google_ads', 'Paused', 'paused', 'Campaign is paused (camelCase variant)'), + ('google_ads', 'REMOVED', 'cancelled', 'Campaign has been removed'), + ('google_ads', 'Removed', 'cancelled', 'Campaign has been removed (camelCase variant)'), + ('google_ads', 'UNKNOWN', 'error', 'Unknown status from Google Ads API'), + ('google_ads', 'UNSPECIFIED', 'error', 'Unspecified status from Google Ads API') +) AS src (channelType, providerStatus, platformStatus, description) +ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus +WHEN NOT MATCHED THEN + INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription) + VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description); +GO + +-- ============================================================ +-- Seed: Meta (Facebook / Instagram) +-- ============================================================ +MERGE dbo.tbProviderStatusMap AS tgt +USING (VALUES + ('meta', 'ACTIVE', 'active', 'Campaign is delivering'), + ('meta', 'PAUSED', 'paused', 'Campaign paused by advertiser'), + ('meta', 'DELETED', 'cancelled', 'Campaign deleted'), + ('meta', 'ARCHIVED', 'completed', 'Campaign archived after completion'), + ('meta', 'IN_PROCESS', 'pending', 'Campaign is being processed'), + ('meta', 'WITH_ISSUES', 'error', 'Campaign has delivery issues'), + ('meta', 'CAMPAIGN_PAUSED', 'paused', 'Parent campaign is paused'), + ('meta', 'ADSET_PAUSED', 'paused', 'Ad set level pause'), + ('meta', 'DISAPPROVED', 'error', 'Ad/campaign disapproved by review'), + ('meta', 'PREAPPROVED', 'pending', 'Preapproved, awaiting final review'), + ('meta', 'PENDING_REVIEW', 'pending', 'Awaiting Meta ad review'), + ('meta', 'PENDING_BILLING_INFO','error', 'Billing information required') +) AS src (channelType, providerStatus, platformStatus, description) +ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus +WHEN NOT MATCHED THEN + INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription) + VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description); +GO + +-- ============================================================ +-- Seed: TikTok +-- ============================================================ +MERGE dbo.tbProviderStatusMap AS tgt +USING (VALUES + ('tiktok', 'ENABLE', 'active', 'Campaign is active and delivering'), + ('tiktok', 'CAMPAIGN_STATUS_ENABLE', 'active', 'Campaign enabled (prefixed variant)'), + ('tiktok', 'DISABLE', 'paused', 'Campaign disabled by advertiser'), + ('tiktok', 'CAMPAIGN_STATUS_DISABLE', 'paused', 'Campaign disabled (prefixed variant)'), + ('tiktok', 'DELETE', 'cancelled', 'Campaign deleted'), + ('tiktok', 'CAMPAIGN_STATUS_DELETE', 'cancelled', 'Campaign deleted (prefixed variant)'), + ('tiktok', 'BUDGET_EXCEED', 'paused', 'Budget limit exceeded'), + ('tiktok', 'CAMPAIGN_STATUS_BUDGET_EXCEED', 'paused', 'Budget exceeded (prefixed variant)'), + ('tiktok', 'ADVERTISER_AUDIT_DENY', 'error', 'Advertiser account audit denied'), + ('tiktok', 'CAMPAIGN_STATUS_ADVERTISER_AUDIT_DENY','error', 'Audit denied (prefixed variant)'), + ('tiktok', 'NOT_DELETE', 'active', 'Campaign exists and is not deleted'), + ('tiktok', 'ADVERTISER_AUDIT', 'pending', 'Advertiser account under audit'), + ('tiktok', 'CAMPAIGN_STATUS_ADVERTISER_AUDIT', 'pending', 'Under audit (prefixed variant)'), + ('tiktok', 'REAUDIT', 'pending', 'Campaign under re-audit'), + ('tiktok', 'ALL', 'active', 'TikTok ALL filter status (treat as active)') +) AS src (channelType, providerStatus, platformStatus, description) +ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus +WHEN NOT MATCHED THEN + INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription) + VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description); +GO + +-- ============================================================ +-- Seed: Common / Internal (platform-generated statuses) +-- ============================================================ +MERGE dbo.tbProviderStatusMap AS tgt +USING (VALUES + ('_common', 'submitted', 'active', 'Successfully dispatched to provider'), + ('_common', 'pending_review', 'pending', 'Awaiting provider review'), + ('_common', 'stub_provider', 'pending', 'Stub provider — no real dispatch yet'), + ('_common', 'approved', 'active', 'Provider approved the campaign'), + ('_common', 'rejected', 'error', 'Provider rejected the campaign'), + ('_common', 'suspended', 'paused', 'Campaign suspended by provider'), + ('_common', 'budget_depleted', 'paused', 'Budget fully consumed'), + ('_common', 'expired', 'completed', 'Campaign reached its end date'), + ('_common', 'archived', 'completed', 'Campaign archived'), + ('_common', 'deleted', 'cancelled', 'Campaign deleted'), + ('_common', 'in_process', 'pending', 'Campaign is being processed'), + ('_common', 'in_review', 'pending', 'Campaign is under review'), + ('_common', 'learning', 'active', 'Campaign in learning/optimization phase'), + ('_common', 'limited', 'active', 'Campaign serving but limited (budget, targeting)') +) AS src (channelType, providerStatus, platformStatus, description) +ON tgt.psmChannelType = src.channelType AND tgt.psmProviderStatus = src.providerStatus +WHEN NOT MATCHED THEN + INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription) + VALUES (src.channelType, src.providerStatus, src.platformStatus, src.description); +GO + +-- ============================================================ +-- Stored Procedure: spProviderStatusMap +-- ============================================================ +-- Actions: list, get, upsert, delete +-- Follows standard JSON request/response pattern. +-- ============================================================ +CREATE OR ALTER PROCEDURE dbo.spProviderStatusMap + @Action VARCHAR(20), + @Rqst NVARCHAR(MAX) = '{}', + @Resp NVARCHAR(MAX) OUTPUT +AS +BEGIN + SET NOCOUNT ON; + + -- ── LIST ── + IF @Action = 'list' + BEGIN + DECLARE @filterChannel VARCHAR(50) = JSON_VALUE(@Rqst, '$.channelType'); + + SET @Resp = ( + SELECT + psmId AS id, + psmChannelType AS channelType, + psmProviderStatus AS providerStatus, + psmPlatformStatus AS platformStatus, + psmDescription AS [description], + psmIsActive AS isActive + FROM dbo.tbProviderStatusMap + WHERE psmIsActive = 1 + AND (@filterChannel IS NULL OR psmChannelType = @filterChannel) + ORDER BY psmChannelType, psmProviderStatus + FOR JSON PATH, ROOT('data') + ); + + IF @Resp IS NULL SET @Resp = '{"data":[]}'; + SET @Resp = '{"ok":true,' + SUBSTRING(@Resp, 2, LEN(@Resp)); + RETURN; + END + + -- ── GET ── + IF @Action = 'get' + BEGIN + DECLARE @getId INT = JSON_VALUE(@Rqst, '$.id'); + + SET @Resp = ( + SELECT + psmId AS id, + psmChannelType AS channelType, + psmProviderStatus AS providerStatus, + psmPlatformStatus AS platformStatus, + psmDescription AS [description], + psmIsActive AS isActive + FROM dbo.tbProviderStatusMap + WHERE psmId = @getId + FOR JSON PATH, WITHOUT_ARRAY_WRAPPER + ); + + IF @Resp IS NULL + BEGIN + SET @Resp = '{"ok":false,"error":"Mapping not found"}'; + RETURN; + END + + SET @Resp = '{"ok":true,"data":' + @Resp + '}'; + RETURN; + END + + -- ── UPSERT ── + IF @Action = 'upsert' + BEGIN + DECLARE @uChannelType VARCHAR(50) = JSON_VALUE(@Rqst, '$.channelType'); + DECLARE @uProviderStatus VARCHAR(100) = JSON_VALUE(@Rqst, '$.providerStatus'); + DECLARE @uPlatformStatus VARCHAR(20) = JSON_VALUE(@Rqst, '$.platformStatus'); + DECLARE @uDescription NVARCHAR(200) = JSON_VALUE(@Rqst, '$.description'); + + IF @uChannelType IS NULL OR @uProviderStatus IS NULL OR @uPlatformStatus IS NULL + BEGIN + SET @Resp = '{"ok":false,"error":"channelType, providerStatus, and platformStatus are required"}'; + RETURN; + END + + IF @uPlatformStatus NOT IN ('draft','staged','pending','active','paused','completed','cancelled','error') + BEGIN + SET @Resp = '{"ok":false,"error":"Invalid platformStatus. Must be: draft, staged, pending, active, paused, completed, cancelled, error"}'; + RETURN; + END + + MERGE dbo.tbProviderStatusMap AS tgt + USING (SELECT @uChannelType, @uProviderStatus) AS src (ct, ps) + ON tgt.psmChannelType = src.ct AND tgt.psmProviderStatus = src.ps + WHEN MATCHED THEN + UPDATE SET + psmPlatformStatus = @uPlatformStatus, + psmDescription = COALESCE(@uDescription, psmDescription), + psmIsActive = 1, + psmUpdatedAt = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (psmChannelType, psmProviderStatus, psmPlatformStatus, psmDescription) + VALUES (@uChannelType, @uProviderStatus, @uPlatformStatus, @uDescription); + + SET @Resp = '{"ok":true,"message":"Mapping saved"}'; + RETURN; + END + + -- ── DELETE (soft) ── + IF @Action = 'delete' + BEGIN + DECLARE @dId INT = JSON_VALUE(@Rqst, '$.id'); + + UPDATE dbo.tbProviderStatusMap + SET psmIsActive = 0, psmUpdatedAt = SYSUTCDATETIME() + WHERE psmId = @dId; + + SET @Resp = '{"ok":true,"message":"Mapping deactivated"}'; + RETURN; + END + + SET @Resp = '{"ok":false,"error":"Unknown action: ' + ISNULL(@Action,'null') + '"}'; +END +GO diff --git a/Gateway/Migrations/SecurityHardening.sql b/Gateway/Migrations/SecurityHardening.sql new file mode 100644 index 0000000..a989b3c --- /dev/null +++ b/Gateway/Migrations/SecurityHardening.sql @@ -0,0 +1,174 @@ +-- ════════════════════════════════════════════════════════════════ +-- SECURITY HARDENING: Stored Procedure Ownership Enforcement +-- ════════════════════════════════════════════════════════════════ +-- +-- PURPOSE: Add WHERE clientId checks to all stored procedures +-- that accept initiativeId, channelCampaignId, or wizardId. +-- +-- The Gateway now passes clientId in all JSON requests. +-- These proc changes enforce ownership at the database level +-- as a SECOND layer of defense (the Gateway guard is the first). +-- +-- APPLY: Run against your AdPlatform SQL Server database. +-- TEST FIRST in dev/staging before production. +-- ════════════════════════════════════════════════════════════════ + +-- ────────────────────────────────────────────────── +-- PATTERN: Inside each proc's @Action handler, add: +-- +-- DECLARE @clientId NVARCHAR(100) = JSON_VALUE(@Rqst, '$.clientId'); +-- +-- -- Then in every SELECT/UPDATE/DELETE that references an initiative: +-- WHERE i.initiativeId = @initiativeId +-- AND i.clientId = @clientId -- ← ADD THIS +-- +-- -- If the WHERE filters out the row, return "not found" +-- -- (same response as non-existent ID — prevents enumeration) +-- ────────────────────────────────────────────────── + +PRINT '=== Security Hardening Migration ===' +PRINT '' + +-- ────────────────────────────────────────────────── +-- 1. spInitiative — get, update, updateStatus, delete +-- ────────────────────────────────────────────────── + +PRINT 'Hardening spInitiative...' + +-- Example pattern for the "get" action: +-- (Apply this pattern to get, update, updateStatus, delete actions) +-- +-- Current: +-- SELECT ... FROM tbInitiative WHERE initiativeId = @initiativeId +-- +-- Hardened: +-- DECLARE @clientId NVARCHAR(100) = JSON_VALUE(@Rqst, '$.clientId') +-- SELECT ... FROM tbInitiative +-- WHERE initiativeId = @initiativeId +-- AND (@clientId IS NULL OR clientId = @clientId) +-- +-- The @clientId IS NULL fallback allows internal/system calls +-- (like InitiativeLaunchService) that don't pass clientId to still work. + +-- IMPORTANT: Apply to each action in spInitiative: +-- 'get' → WHERE initiativeId = @id AND (@clientId IS NULL OR clientId = @clientId) +-- 'update' → same +-- 'updateStatus' → same +-- 'delete' → same +-- 'list' → already scoped by clientId (verify it uses = not LIKE) + +GO + +-- ────────────────────────────────────────────────── +-- 2. spChannelCampaign — get, sync +-- ────────────────────────────────────────────────── + +PRINT 'Hardening spChannelCampaign...' + +-- Channel campaigns link to initiatives, so ownership requires a JOIN: +-- +-- Current: +-- SELECT cc.* FROM tbChannelCampaign cc WHERE cc.channelCampaignId = @id +-- +-- Hardened: +-- SELECT cc.* +-- FROM tbChannelCampaign cc +-- JOIN tbInitiative i ON cc.initiativeId = i.initiativeId +-- WHERE cc.channelCampaignId = @id +-- AND (@clientId IS NULL OR i.clientId = @clientId) +-- +-- For 'sync' action: This is now admin-only in the Gateway, +-- but add the JOIN anyway for defense in depth. + +GO + +-- ────────────────────────────────────────────────── +-- 3. spCampaignWizard — get, updateStep, setStep, submit, updateStatus, delete +-- ────────────────────────────────────────────────── + +PRINT 'Hardening spCampaignWizard...' + +-- Current: +-- SELECT ... FROM tbCampaignWizard WHERE wizardId = @wizardId +-- +-- Hardened: +-- DECLARE @clientId NVARCHAR(100) = JSON_VALUE(@Rqst, '$.clientId') +-- SELECT ... FROM tbCampaignWizard +-- WHERE wizardId = @wizardId +-- AND (@clientId IS NULL OR clientId = @clientId) + +GO + +-- ────────────────────────────────────────────────── +-- 4. spAllocation — all actions +-- ────────────────────────────────────────────────── + +PRINT 'Hardening spAllocation...' + +-- Allocations link to initiatives: +-- +-- Hardened: +-- JOIN tbInitiative i ON a.initiativeId = i.initiativeId +-- WHERE a.initiativeId = @initiativeId +-- AND (@clientId IS NULL OR i.clientId = @clientId) + +GO + +-- ────────────────────────────────────────────────── +-- 5. Status transition validation at DB level (optional extra layer) +-- ────────────────────────────────────────────────── + +PRINT 'Adding status transition function...' + +-- Create a function the procs can call to validate transitions: +IF OBJECT_ID('dbo.fnIsValidStatusTransition', 'FN') IS NOT NULL + DROP FUNCTION dbo.fnIsValidStatusTransition +GO + +CREATE FUNCTION dbo.fnIsValidStatusTransition( + @currentStatus VARCHAR(20), + @requestedStatus VARCHAR(20), + @isSystem BIT = 0 -- 1 = system/admin (broader transitions allowed) +) +RETURNS BIT +AS +BEGIN + -- System can do anything + IF @isSystem = 1 RETURN 1 + + -- Client-allowed transitions + IF @currentStatus = 'active' AND @requestedStatus = 'paused' RETURN 1 + IF @currentStatus = 'paused' AND @requestedStatus = 'active' RETURN 1 + IF @currentStatus IN ('draft','staged','pending','active','paused') + AND @requestedStatus = 'cancelled' RETURN 1 + + RETURN 0 +END +GO + +-- ────────────────────────────────────────────────── +-- 6. spGoogleAccount — validate +-- ────────────────────────────────────────────────── + +PRINT 'Verifying spGoogleAccount...' + +-- The validate action should verify that the customerId +-- belongs to the requesting client. Current implementation +-- may not check this — verify and add: +-- +-- WHERE a.customerId = @customerId +-- AND a.clientId = @clientId + +GO + +PRINT '' +PRINT '=== Migration complete ===' +PRINT 'NOTE: This is a TEMPLATE. Review each stored procedure and apply' +PRINT 'the ownership WHERE clauses to match your exact table/column names.' +PRINT '' +PRINT 'After applying, test:' +PRINT ' 1. Normal user can only see their own initiatives/wizards' +PRINT ' 2. User A cannot access User B resources by guessing IDs' +PRINT ' 3. LaunchService (no clientId) can still read initiatives' +PRINT ' 4. Admin role can sync channel status' +GO diff --git a/Gateway/Models/ForecastModels.cs b/Gateway/Models/ForecastModels.cs new file mode 100644 index 0000000..bcecf5b --- /dev/null +++ b/Gateway/Models/ForecastModels.cs @@ -0,0 +1,137 @@ +namespace Gateway.Models; + +// ════════════════════════════════════════════════ +// Request: Client → Gateway +// ════════════════════════════════════════════════ + +public sealed class ChannelForecastRequest +{ + /// Advertising objective: awareness, traffic, leads, sales + public string Objective { get; set; } = "traffic"; + + /// Business category from wizard Step 1 + public string? BusinessCategory { get; set; } + + /// Keywords from URL analysis (Step 1) + public List Keywords { get; set; } = new(); + + /// Geo targeting from audience step + public ForecastGeoTargeting? GeoTargeting { get; set; } + + /// Audience parameters from Step 2 + public ForecastAudience? Audience { get; set; } + + /// Monthly budget in whole dollars + public decimal MonthlyBudget { get; set; } + + /// Channels to estimate (defaults to all selected) + public List? Channels { get; set; } +} + +public sealed class ForecastGeoTargeting +{ + public List? ZipCodes { get; set; } + public double? RadiusMiles { get; set; } + public List? GeoTargetIds { get; set; } +} + +public sealed class ForecastAudience +{ + public int? AgeMin { get; set; } + public int? AgeMax { get; set; } + public List? Genders { get; set; } + public List? Interests { get; set; } +} + +// ════════════════════════════════════════════════ +// Response: Gateway → Client (normalized) +// ════════════════════════════════════════════════ + +public sealed class ChannelForecastResponse +{ + public bool Ok { get; set; } = true; + public string Objective { get; set; } = string.Empty; + public decimal TotalBudget { get; set; } + public List Channels { get; set; } = new(); + public ForecastRecommendation? Recommendation { get; set; } + public ForecastMeta Metadata { get; set; } = new(); +} + +public sealed class ChannelEstimate +{ + public string Provider { get; set; } = string.Empty; + public int AllocationPercent { get; set; } + public decimal AllocatedBudget { get; set; } + public ChannelEstimateMetrics Estimates { get; set; } = new(); + public double EfficiencyScore { get; set; } + public string StrengthLabel { get; set; } = string.Empty; + public string Confidence { get; set; } = "none"; + public string DataSource { get; set; } = "none"; +} + +public sealed class ChannelEstimateMetrics +{ + public double Impressions { get; set; } + public double? Reach { get; set; } + public double Clicks { get; set; } + public double Conversions { get; set; } + public decimal AvgCpc { get; set; } + public decimal AvgCpm { get; set; } + public decimal? EstimatedCpa { get; set; } + public double Ctr { get; set; } +} + +public sealed class ForecastRecommendation +{ + public string Summary { get; set; } = string.Empty; + public List Highlights { get; set; } = new(); +} + +public sealed class ForecastMeta +{ + public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow; + public string ForecastPeriod { get; set; } = "30 days"; +} + +// ════════════════════════════════════════════════ +// Objective-weighted scoring +// ════════════════════════════════════════════════ + +public sealed class MetricWeights +{ + public double Reach { get; } + public double Impressions { get; } + public double Cpm { get; } + public double Clicks { get; } + public double Cpc { get; } + public double Ctr { get; } + public double Conversions { get; } + public double Cpa { get; } + + public MetricWeights(double reach, double impressions, double cpm, + double clicks, double cpc, double ctr, double conversions, double cpa) + { + Reach = reach; Impressions = impressions; Cpm = cpm; + Clicks = clicks; Cpc = cpc; Ctr = ctr; + Conversions = conversions; Cpa = cpa; + } +} + +public static class ObjectiveWeights +{ + public static readonly Dictionary Weights = new(StringComparer.OrdinalIgnoreCase) + { + // reach imp cpm clicks cpc ctr conv cpa + ["awareness"] = new MetricWeights(0.35, 0.25, 0.20, 0.05, 0.05, 0.05, 0.00, 0.00), + ["traffic"] = new MetricWeights(0.05, 0.10, 0.10, 0.30, 0.30, 0.15, 0.00, 0.00), + ["leads"] = new MetricWeights(0.05, 0.05, 0.05, 0.15, 0.15, 0.10, 0.25, 0.20), + ["sales"] = new MetricWeights(0.05, 0.05, 0.05, 0.10, 0.10, 0.10, 0.30, 0.25), + }; + + /// Fallback: balanced weights if objective not recognized + public static readonly MetricWeights Default = + new(0.10, 0.10, 0.10, 0.20, 0.20, 0.10, 0.10, 0.10); + + public static MetricWeights For(string objective) + => Weights.TryGetValue(objective, out var w) ? w : Default; +} diff --git a/Gateway/Models/MultiChannelConfig.cs b/Gateway/Models/MultiChannelConfig.cs new file mode 100644 index 0000000..e88bf44 --- /dev/null +++ b/Gateway/Models/MultiChannelConfig.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Serialization; + +namespace Gateway.Models; + +/// +/// Configuration for a single advertising channel provider. +/// Populated from database (tbChannelConfig) via ChannelConfigService. +/// Drives Gateway routing, wizard behavior, and validation. +/// +public sealed class ProviderConfig +{ + /// Channel type key (e.g., "google_ads", "meta", "tiktok"). + public string ChannelType { get; set; } = ""; + + /// Display name for UI. + public string DisplayName { get; set; } = ""; + + /// Short description shown in channel selection. + public string? Description { get; set; } + + /// Whether this channel is currently available for new campaigns. + public bool Enabled { get; set; } + + /// Provider service endpoint URL (null = stub/disabled). + public string? Endpoint { get; set; } + + /// Internal API key for provider service authentication. + public string? InternalKey { get; set; } + + /// Whether this is a stub container (test mode). + public bool IsStub { get; set; } + + /// Minimum daily budget in USD for this channel. + public decimal MinDailyBudget { get; set; } + + /// Minimum monthly budget in USD for this channel. + public decimal MinMonthlyBudget { get; set; } + + /// Supported unified objectives (awareness, traffic, conversions, leads, sales). + public List SupportedObjectives { get; set; } = new(); + + /// Supported creative formats (text, image, video, carousel, etc.). + public List SupportedCreativeFormats { get; set; } = new(); + + /// Estimated approval time in hours. + public int ApprovalEstimateHours { get; set; } + + /// How often to refresh metrics from this provider (minutes). + public int MetricsRefreshIntervalMinutes { get; set; } = 60; + + /// Icon identifier for UI (e.g., "google", "meta", "tiktok"). + public string? Icon { get; set; } + + /// Brand color hex for UI (e.g., "#4285F4"). + public string? Color { get; set; } + + /// Auth method used by provider (oauth2, api_key, mcc). + public string? AuthMethod { get; set; } + + /// Key Vault secret name for provider credentials. + public string? KeyVaultSecretName { get; set; } + + /// + /// Maps provider-specific status strings to platform statuses. + /// Keys are raw provider values (case-insensitive), values are platform statuses + /// (draft, staged, pending, active, paused, completed, cancelled, error). + /// + public Dictionary StatusMappings { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +/// +/// Global allocation and multi-channel settings. +/// Loaded from appsettings.json "MultiChannel:Allocation" section (simple scalars only). +/// +public sealed class AllocationSettings +{ + public decimal MinMultiChannelMonthlyBudget { get; set; } = 500.00m; + public int MaxChannelsPerInitiative { get; set; } = 5; + public string DefaultAllocationStrategy { get; set; } = "template"; + public int PerformanceEvalIntervalDays { get; set; } = 7; + public int PerformanceLookbackDays { get; set; } = 14; + public int PerformanceLearningPeriodDays { get; set; } = 14; + public decimal MaxAllocationShiftPct { get; set; } = 15.00m; + public decimal MinChannelAllocationPct { get; set; } = 10.00m; + public decimal MaxChannelAllocationPct { get; set; } = 80.00m; +} + +/// +/// Root multi-channel configuration. +/// Channels are populated from DB via ChannelConfigService at startup. +/// Allocation settings loaded from appsettings.json (simple scalars). +/// +public sealed class MultiChannelConfig +{ + /// Per-provider configurations, keyed by channel type. + public Dictionary Channels { get; set; } = new(); + + /// Global allocation settings. + public AllocationSettings Allocation { get; set; } = new(); + + /// Get only enabled providers. + [JsonIgnore] + public IEnumerable EnabledChannels => + Channels.Values.Where(c => c.Enabled); + + /// Look up a provider config by channel type. + public ProviderConfig? GetChannel(string channelType) => + Channels.TryGetValue(channelType, out var config) ? config : null; +} diff --git a/Gateway/Program.cs b/Gateway/Program.cs index 2c63ad7..c456390 100644 --- a/Gateway/Program.cs +++ b/Gateway/Program.cs @@ -1,4 +1,6 @@ +using Azure.Storage.Blobs; using Gateway.Data; +using Gateway.Models; using Gateway.ProviderClients; using Gateway.Security; using Gateway.Services; @@ -20,7 +22,18 @@ builder.Services.AddSwaggerGen(); // Data & business services builder.Services.AddScoped(); -builder.Services.AddScoped(); + +// Channel configuration (loaded from DB at startup, not appsettings) +builder.Services.AddSingleton(); + +// For consumers injecting MultiChannelConfig directly (e.g. ExecutionService) +builder.Services.AddScoped(sp => + sp.GetRequiredService().Current); + +// For consumers injecting IOptions (e.g. InitiativeController, InitiativeLaunchService) +builder.Services.AddSingleton>(sp => + Microsoft.Extensions.Options.Options.Create( + sp.GetRequiredService().Current)); // Authentication context (scoped - one per request) builder.Services.AddScoped(); @@ -38,8 +51,86 @@ builder.Services.AddHttpClient(client => // HTTP client factory for ExecutionService builder.Services.AddHttpClient(); +// -------------------- +// Blob Storage (for Creative images) +// -------------------- +var blobConnectionString = builder.Configuration["BlobStorage:ConnectionString"] + ?? Environment.GetEnvironmentVariable("BLOB_STORAGE_CONNECTION_STRING"); + +if (!string.IsNullOrEmpty(blobConnectionString)) +{ + builder.Services.AddSingleton(new BlobServiceClient(blobConnectionString)); + Console.WriteLine("[Gateway] Blob storage configured"); +} +else +{ + // Register null so DI can resolve ImageStorageService + builder.Services.AddSingleton(sp => null!); + Console.WriteLine("[Gateway] Blob storage not configured - Creative images will use source URLs"); +} + +// ImageStorageService (works with or without blob storage configured) +builder.Services.AddScoped(); + +// ExecutionService (depends on ImageStorageService) +builder.Services.AddScoped(); + +// Metric sync orchestration (pulls from providers, writes to DB, triggers evaluation) +builder.Services.AddScoped(); + +// Initiative launch orchestration service +builder.Services.AddScoped(); + +// Authorization guard (ownership, roles, status transitions) +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +// Provider status normalization +builder.Services.AddSingleton(); + +// Forecast service for channel performance estimates (local fallback) +builder.Services.AddSingleton(); + +// IntelligenceApi client — routes forecast requests to the category-aware engine container +// Falls back to ForecastService if INTELLIGENCE_API_URL is not configured +builder.Services.AddSingleton(); + var app = builder.Build(); +// ──────────────────────────────────────────────── +// Load channel config from database (before serving requests) +// ──────────────────────────────────────────────── +try +{ + var channelConfigSvc = app.Services.GetRequiredService(); + await channelConfigSvc.LoadAsync(); + Console.WriteLine("[Gateway] Channel config loaded from database"); +} +catch (Exception ex) +{ + Console.WriteLine($"[Gateway] ⚠️ Channel config DB load failed — using defaults: {ex.Message}"); +} + +// ──────────────────────────────────────────────── +// SECURITY: Startup environment checks +// ──────────────────────────────────────────────── +var env = app.Environment; +var allowDevBypass = builder.Configuration.GetValue("Auth:AllowDevBypass"); + +if (allowDevBypass && !env.IsDevelopment()) +{ + Console.WriteLine("╔══════════════════════════════════════════════════════════╗"); + Console.WriteLine("║ ⚠️ WARNING: Auth:AllowDevBypass=true in NON-DEV env! ║"); + Console.WriteLine("║ This allows X-Dev-ClientId header to bypass auth. ║"); + Console.WriteLine("║ Remove this setting in production! ║"); + Console.WriteLine("╚══════════════════════════════════════════════════════════╝"); +} + +if (env.IsDevelopment()) +{ + Console.WriteLine("[Gateway] ⚠️ Development mode — dev bypass headers accepted"); +} + // -------------------- // Middleware pipeline // -------------------- @@ -49,12 +140,31 @@ app.UseSwagger(); app.UseSwaggerUI(); // Health check endpoint (before auth & logging) -app.MapGet("/health", () => Results.Ok(new +app.MapGet("/health", (ChannelConfigService channelSvc, IConfiguration config) => { - ok = true, - service = "Gateway", - timestamp = DateTimeOffset.UtcNow -})); + var blobConfigured = !string.IsNullOrEmpty(config["BlobStorage:ConnectionString"]) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BLOB_STORAGE_CONNECTION_STRING")); + + var mcConfig = channelSvc.Current; + + return Results.Ok(new + { + ok = true, + service = "Gateway", + timestamp = DateTimeOffset.UtcNow, + config = new + { + blobStorageConfigured = blobConfigured, + blobContainer = config["BlobStorage:ContainerName"] ?? "creative-images", + enabledChannels = mcConfig.EnabledChannels.Select(c => new + { + c.ChannelType, + c.DisplayName, + c.IsStub + }) + } + }); +}); // Root endpoint app.MapGet("/", () => Results.Ok(new diff --git a/Gateway/Security/AuthorizationGuard.cs b/Gateway/Security/AuthorizationGuard.cs new file mode 100644 index 0000000..13db57d --- /dev/null +++ b/Gateway/Security/AuthorizationGuard.cs @@ -0,0 +1,396 @@ +using Gateway.Data; +using Gateway.Models; +using Microsoft.AspNetCore.Http; +using System.Text.Json; + +namespace Gateway.Security; + +/// +/// Centralized authorization guard for resource ownership, role checks, +/// and status transition enforcement. +/// +/// DEFENSE IN DEPTH: +/// Layer 1: Middleware authenticates session → populates ClientContext +/// Layer 2: This guard validates resource ownership before operations +/// Layer 3: Stored procs SHOULD also have WHERE clientId = @clientId +/// +/// All public methods return (bool Allowed, string? Error) to keep +/// controller code clean and consistent. +/// +public sealed class AuthorizationGuard +{ + private readonly SqlService _sql; + private readonly ClientContext _client; + private readonly ILogger _log; + private readonly IConfiguration _config; + private readonly IHttpContextAccessor _http; + + public AuthorizationGuard( + SqlService sql, + ClientContext client, + ILogger log, + IConfiguration config, + IHttpContextAccessor http) + { + _sql = sql; + _client = client; + _log = log; + _config = config; + _http = http; + } + + // ════════════════════════════════════════════════ + // SERVICE KEY CHECK + // ════════════════════════════════════════════════ + + /// + /// Validate an internal service-to-service call via X-Service-Key header. + /// Used by provider containers and background services that cannot carry + /// a CIAM session token. Configure via INTERNAL_SERVICE_KEY env var. + /// + public (bool Ok, string? Error) RequireServiceKey() + { + var expected = _config["INTERNAL_SERVICE_KEY"]; + if (string.IsNullOrWhiteSpace(expected)) + { + _log.LogWarning("[AuthZ] INTERNAL_SERVICE_KEY not configured — service key check denied"); + return (false, "Service key not configured"); + } + + var provided = _http.HttpContext?.Request.Headers["X-Service-Key"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(provided) || provided != expected) + { + _log.LogWarning("[AuthZ] Invalid or missing X-Service-Key"); + return (false, "Valid service key required"); + } + + return (true, null); + } + + // ════════════════════════════════════════════════ + // BASIC AUTH CHECKS + // ════════════════════════════════════════════════ + + /// Require authenticated session with a valid ClientId. + public (bool Ok, string? Error) RequireAuth() + { + if (!_client.IsAuthenticated) + return (false, "Authentication required"); + return (true, null); + } + + /// Require specific role(s). Case-insensitive. + public (bool Ok, string? Error) RequireRole(params string[] allowedRoles) + { + var (ok, err) = RequireAuth(); + if (!ok) return (ok, err); + + if (string.IsNullOrWhiteSpace(_client.Role)) + return (false, "No role assigned"); + + if (!allowedRoles.Any(r => string.Equals(_client.Role, r, StringComparison.OrdinalIgnoreCase))) + { + _log.LogWarning( + "[AuthZ] Role denied | ClientId={ClientId} Role={Role} Required={Required}", + _client.ClientId, _client.Role, string.Join(",", allowedRoles)); + return (false, "Insufficient permissions"); + } + + return (true, null); + } + + /// Require admin role. + public (bool Ok, string? Error) RequireAdmin() + => RequireRole("admin"); + + // ════════════════════════════════════════════════ + // INITIATIVE OWNERSHIP + // ════════════════════════════════════════════════ + + /// + /// Verify initiative belongs to the authenticated client. + /// Returns the initiative JSON on success (avoids double-fetch). + /// + public async Task VerifyInitiativeOwnerAsync(long initiativeId, CancellationToken ct) + { + var (ok, err) = RequireAuth(); + if (!ok) return OwnershipResult.Denied(err!); + + try + { + var resp = await _sql.ExecProcAsync( + SqlNames.Procs.Initiative, "get", + JsonSerializer.Serialize(new { initiativeId }), ct: ct); + + if (string.IsNullOrWhiteSpace(resp)) + return OwnershipResult.Denied("Initiative not found"); + + using var doc = JsonDocument.Parse(resp); + var root = doc.RootElement; + + if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + return OwnershipResult.Denied("Initiative not found"); + + // Extract clientId from response — check both clean and prefixed shapes + var initiative = root.TryGetProperty("initiative", out var initEl) ? initEl : root; + var ownerClientId = + initiative.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() : + initiative.TryGetProperty("iniClientId", out var iniCidProp) ? iniCidProp.GetString() : + null; + + if (string.IsNullOrWhiteSpace(ownerClientId)) + { + _log.LogWarning( + "[AuthZ] Initiative {Id} has no clientId — ownership check inconclusive, denying", + initiativeId); + return OwnershipResult.Denied("Initiative ownership could not be verified"); + } + + if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase)) + { + _log.LogWarning( + "[AuthZ] IDOR attempt | InitiativeId={InitiativeId} Owner={Owner} Requester={Requester}", + initiativeId, ownerClientId, _client.ClientId); + return OwnershipResult.Denied("Initiative not found"); // Don't reveal existence + } + + // Extract current status for transition validation — check both shapes + var status = + initiative.TryGetProperty("status", out var stProp) ? stProp.GetString() : + initiative.TryGetProperty("iniStatus", out var iniStProp) ? iniStProp.GetString() : + null; + + return OwnershipResult.Allowed(resp, status); + } + catch (Exception ex) + { + _log.LogError(ex, "[AuthZ] Ownership check failed for initiative {Id}", initiativeId); + return OwnershipResult.Denied("Authorization check failed"); + } + } + + // ════════════════════════════════════════════════ + // CHANNEL CAMPAIGN OWNERSHIP + // ════════════════════════════════════════════════ + + /// + /// Verify channel campaign belongs to the authenticated client. + /// Follows channelCampaign → initiative → client ownership chain. + /// + public async Task VerifyChannelOwnerAsync(long channelCampaignId, CancellationToken ct) + { + var (ok, err) = RequireAuth(); + if (!ok) return OwnershipResult.Denied(err!); + + try + { + var resp = await _sql.ExecProcAsync( + SqlNames.Procs.ChannelCampaign, "get", + JsonSerializer.Serialize(new { channelCampaignId }), ct: ct); + + if (string.IsNullOrWhiteSpace(resp)) + return OwnershipResult.Denied("Channel campaign not found"); + + using var doc = JsonDocument.Parse(resp); + var root = doc.RootElement; + + if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + return OwnershipResult.Denied("Channel campaign not found"); + + // Get initiativeId, then check initiative ownership + var campaign = root.TryGetProperty("channelCampaign", out var ccEl) ? ccEl : root; + var initiativeId = + campaign.TryGetProperty("initiativeId", out var initIdProp) ? initIdProp.GetInt64() : + campaign.TryGetProperty("chcInitiativeId", out var chcInitProp) ? chcInitProp.GetInt64() : + 0; + + if (initiativeId <= 0) + return OwnershipResult.Denied("Channel campaign not found"); + + // Delegate to initiative ownership check + return await VerifyInitiativeOwnerAsync(initiativeId, ct); + } + catch (Exception ex) + { + _log.LogError(ex, "[AuthZ] Channel ownership check failed for {Id}", channelCampaignId); + return OwnershipResult.Denied("Authorization check failed"); + } + } + + // ════════════════════════════════════════════════ + // WIZARD OWNERSHIP + // ════════════════════════════════════════════════ + + /// + /// Verify wizard belongs to the authenticated client. + /// + public async Task VerifyWizardOwnerAsync(string wizardId, CancellationToken ct) + { + var (ok, err) = RequireAuth(); + if (!ok) return OwnershipResult.Denied(err!); + + try + { + var resp = await _sql.ExecProcAsync( + "dbo.spCampaignWizard", "get", + JsonSerializer.Serialize(new { wizardId }), ct: ct); + + if (string.IsNullOrWhiteSpace(resp)) + return OwnershipResult.Denied("Wizard not found"); + + using var doc = JsonDocument.Parse(resp); + var root = doc.RootElement; + + if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + return OwnershipResult.Denied("Wizard not found"); + + var wizard = root.TryGetProperty("wizard", out var wzEl) ? wzEl : root; + var ownerClientId = + wizard.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() : + wizard.TryGetProperty("wizClientId", out var wzCidProp) ? wzCidProp.GetString() : + null; + + if (string.IsNullOrWhiteSpace(ownerClientId)) + { + _log.LogWarning("[AuthZ] Wizard {Id} has no clientId", wizardId); + return OwnershipResult.Denied("Wizard ownership could not be verified"); + } + + if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase)) + { + _log.LogWarning( + "[AuthZ] IDOR attempt | WizardId={WizardId} Owner={Owner} Requester={Requester}", + wizardId, ownerClientId, _client.ClientId); + return OwnershipResult.Denied("Wizard not found"); + } + + var status = + wizard.TryGetProperty("status", out var stProp) ? stProp.GetString() : + wizard.TryGetProperty("wizStatus", out var wzStProp) ? wzStProp.GetString() : + null; + return OwnershipResult.Allowed(resp, status); + } + catch (Exception ex) + { + _log.LogError(ex, "[AuthZ] Wizard ownership check failed for {Id}", wizardId); + return OwnershipResult.Denied("Authorization check failed"); + } + } + + // ════════════════════════════════════════════════ + // STATUS TRANSITION VALIDATION + // ════════════════════════════════════════════════ + + /// + /// Validate that a status transition is allowed for client-initiated actions. + /// Internal/system transitions (from launch service, provider callbacks) bypass this. + /// + public (bool Ok, string? Error) ValidateClientStatusTransition( + string? currentStatus, string requestedStatus, string resourceType = "initiative") + { + if (string.IsNullOrWhiteSpace(requestedStatus)) + return (false, "Status is required"); + + // Normalize + var from = (currentStatus ?? "").ToLowerInvariant(); + var to = requestedStatus.ToLowerInvariant(); + + // Client-allowed transitions (restrictive) + var allowed = IsClientTransitionAllowed(from, to); + + if (!allowed) + { + _log.LogWarning( + "[AuthZ] Invalid status transition | {ResourceType} {From} → {To} by ClientId={ClientId}", + resourceType, from, to, _client.ClientId); + return (false, $"Cannot change {resourceType} from '{from}' to '{to}'"); + } + + return (true, null); + } + + /// + /// Whitelist of client-allowed transitions. + /// Everything else requires admin or system action. + /// + private static bool IsClientTransitionAllowed(string from, string to) + { + return (from, to) switch + { + // Pausing: only active campaigns can be paused + ("active", "paused") => true, + + // Resuming: only paused campaigns can be resumed + ("paused", "active") => true, + + // Cancelling: clients can cancel from most pre-completion states + ("draft", "cancelled") => true, + ("staged", "cancelled") => true, + ("pending", "cancelled") => true, + ("active", "cancelled") => true, + ("paused", "cancelled") => true, + + // Everything else is denied at the client level + _ => false + }; + } + + // ════════════════════════════════════════════════ + // BUDGET VALIDATION + // ════════════════════════════════════════════════ + + /// + /// Validate budget against channel minimums. + /// + public (bool Ok, string? Error) ValidateBudget( + decimal totalBudget, string? budgetPeriod, MultiChannelConfig config) + { + if (totalBudget <= 0) + return (false, "Budget must be greater than zero"); + + // Convert to monthly for comparison + var monthlyBudget = (budgetPeriod?.ToLowerInvariant()) switch + { + "daily" => totalBudget * 30.4m, + "weekly" => totalBudget * 4.33m, + _ => totalBudget + }; + + // Check against lowest channel minimum + var minBudget = config.EnabledChannels + .Select(c => c.MinMonthlyBudget) + .DefaultIfEmpty(150m) + .Min(); + + if (monthlyBudget < minBudget) + return (false, $"Monthly budget must be at least ${minBudget:F0}"); + + // Cap at reasonable maximum (safety valve) + if (monthlyBudget > 1_000_000m) + return (false, "Budget exceeds maximum allowed. Contact support for high-spend campaigns."); + + return (true, null); + } +} + +// ════════════════════════════════════════════════ +// Result type +// ════════════════════════════════════════════════ + +public sealed class OwnershipResult +{ + public bool IsAllowed { get; init; } + public string? Error { get; init; } + + /// The raw JSON response from the ownership lookup (avoids re-fetching). + public string? EntityJson { get; init; } + + /// Current status of the entity (for transition validation). + public string? CurrentStatus { get; init; } + + public static OwnershipResult Allowed(string? entityJson = null, string? status = null) + => new() { IsAllowed = true, EntityJson = entityJson, CurrentStatus = status }; + + public static OwnershipResult Denied(string error) + => new() { IsAllowed = false, Error = error }; +} diff --git a/Gateway/Security/ClientAuthMiddleware.cs b/Gateway/Security/ClientAuthMiddleware.cs index 17e7850..ca08c2c 100644 --- a/Gateway/Security/ClientAuthMiddleware.cs +++ b/Gateway/Security/ClientAuthMiddleware.cs @@ -223,7 +223,7 @@ public sealed class ClientAuthMiddleware _logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId); var rqst = JsonSerializer.Serialize(new { sessionToken = token }); - var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted); + var resp = await sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: context.RequestAborted); if (string.IsNullOrWhiteSpace(resp)) { @@ -239,12 +239,13 @@ public sealed class ClientAuthMiddleware var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root; clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null; - clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null; - clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null; - clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null; - clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null; - clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null; - clientContext.IsDevBypass = false; + clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null; + clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null; + clientContext.ClientCategory = data.TryGetProperty("clientCategory", out var ccat) ? ccat.GetString() : null; + clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null; + clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null; + clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null; + clientContext.IsDevBypass = false; _logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}", clientContext.ClientId, clientContext.Email, corrId); diff --git a/Gateway/Security/ClientContext.cs b/Gateway/Security/ClientContext.cs index 5bfb0b3..a4b6cb3 100644 --- a/Gateway/Security/ClientContext.cs +++ b/Gateway/Security/ClientContext.cs @@ -1,60 +1,37 @@ namespace Gateway.Security; /// -/// Holds authenticated client information for the current request. -/// Populated by ClientAuthMiddleware. +/// Holds authenticated identity information for the current request. +/// Populated by MultiProviderAuthMiddleware. /// public sealed class ClientContext { - /// - /// Session ID from session-based auth. - /// - public string? SessionId { get; set; } + public string? SessionId { get; set; } + public string? ClientId { get; set; } // OID (JWT) or platform client ID (session) + public string? TenantId { get; set; } + public string? ClientName { get; set; } + public string? ClientCategory { get; set; } + public string? UserId { get; set; } + public string? Email { get; set; } + public string? Role { get; set; } + public bool IsDevBypass { get; set; } + public string? AuthProvider { get; set; } /// - /// The authenticated client ID (from session, JWT sub claim, or dev header). - /// This identifies the client/organization in our platform. + /// Raw Entra Object ID (oid claim) — always set for Microsoft tokens. + /// Used for identity and activity logging. Distinct from ClientId which may fall + /// back to sub for tokens where oid isn't surfaced as a named claim. /// - public string? ClientId { get; set; } + public string? EntraOid { get; set; } /// - /// Optional tenant ID for the ad platform (e.g., Google Ads customer ID). - /// May be derived from ClientId mapping or passed in request. + /// True when the token was issued by the standard Entra (staff) tenant. /// - public string? TenantId { get; set; } + public bool IsStaff { get; set; } - /// - /// Display name from token or session (if available). - /// - public string? ClientName { get; set; } - - /// - /// User ID from session (if using session auth). - /// - public string? UserId { get; set; } - - /// - /// Email from token or session (if available). - /// - public string? Email { get; set; } - - /// - /// User role from session (admin, user, readonly). - /// - public string? Role { get; set; } - - /// - /// Whether this request was authenticated via dev bypass (vs real auth). - /// - public bool IsDevBypass { get; set; } - - /// - /// The authentication provider used (microsoft, google, etc.) - /// - public string? AuthProvider { get; set; } - - /// - /// True if we have a valid ClientId. - /// + /// True if we have a valid ClientId. public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId); + + /// True if this is an admin session (IsStaff + Role set). + public bool IsAdmin => IsStaff && !string.IsNullOrWhiteSpace(Role); } diff --git a/Gateway/Security/MultiProviderAuthMiddleware.cs b/Gateway/Security/MultiProviderAuthMiddleware.cs index 41bdd22..2d27cad 100644 --- a/Gateway/Security/MultiProviderAuthMiddleware.cs +++ b/Gateway/Security/MultiProviderAuthMiddleware.cs @@ -248,11 +248,26 @@ public sealed class MultiProviderAuthMiddleware } else { - // Standard Entra ID + // Standard Entra ID — could be CIAM tenant or Staff tenant (Tech, Admin) + // Detect by comparing issuer against configured Staff tenant ID + var staffTenantId = _config["Auth:Microsoft:StaffTenantId"]; + var staffClientId = _config["Auth:Microsoft:StaffClientId"]; + + var isStaff = !string.IsNullOrWhiteSpace(staffTenantId) && + jwt.Issuer.Contains(staffTenantId, StringComparison.OrdinalIgnoreCase); + + if (isStaff) + { + tenantId = staffTenantId!; + clientId = staffClientId ?? clientId; + _logger.LogWarning("[Auth] Staff Entra token detected | tenant={Tenant} | Corr={Corr}", tenantId, corrId); + clientContext.IsStaff = true; + } + authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; metadataAddress = $"{authority}/.well-known/openid-configuration"; - validIssuers = new[] - { + validIssuers = new[] + { $"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/" }; @@ -342,9 +357,17 @@ public sealed class MultiProviderAuthMiddleware /// private static void ExtractClaims(ClaimsPrincipal principal, ClientContext clientContext) { + // Always extract oid explicitly — used for activity logging and identity. + // For standard Entra access tokens oid may be under the full claim URI. + var oid = principal.FindFirstValue("oid") + ?? principal.FindFirstValue("http://schemas.microsoft.com/identity/claims/objectidentifier"); + + clientContext.EntraOid = oid; + + // ClientId: prefer oid, fall back to sub clientContext.ClientId = - principal.FindFirstValue("oid") ?? // Microsoft object ID - principal.FindFirstValue("sub") ?? // Standard subject + oid ?? + principal.FindFirstValue("sub") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); clientContext.Email = @@ -389,7 +412,9 @@ public sealed class MultiProviderAuthMiddleware try { var rqst = JsonSerializer.Serialize(new { sessionToken = token }); - var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted); + + var sessionProc = "dbo.spClientSession"; // Gateway handles CIAM client sessions only + var resp = await sql.ExecProcAsync(sessionProc, "validate", rqst, ct: context.RequestAborted); if (string.IsNullOrWhiteSpace(resp)) { @@ -412,8 +437,22 @@ public sealed class MultiProviderAuthMiddleware clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null; clientContext.IsDevBypass = false; - _logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}", - clientContext.ClientId, clientContext.Email, corrId); + // TenantId: session data first, then X-Tenant-Id header fallback + // (In agency model, this is the client's Google Ads customer ID) + clientContext.TenantId = + data.TryGetProperty("tenantId", out var tenId) ? tenId.GetString() : + data.TryGetProperty("googleCustomerId", out var gcid) ? gcid.GetString() : + null; + + // Fall back to X-Tenant-Id header if not in session data + if (string.IsNullOrWhiteSpace(clientContext.TenantId) && + context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader)) + { + clientContext.TenantId = tenantHeader.FirstOrDefault(); + } + + _logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} IsAdmin={IsAdmin} | Corr={Corr}", + clientContext.ClientId, clientContext.Email, clientContext.IsAdmin, corrId); return clientContext.IsAuthenticated; } diff --git a/Gateway/Services/ChannelConfigService.cs b/Gateway/Services/ChannelConfigService.cs new file mode 100644 index 0000000..f8a7b8e --- /dev/null +++ b/Gateway/Services/ChannelConfigService.cs @@ -0,0 +1,299 @@ +using Gateway.Data; +using Gateway.Models; +using System.Text.Json; + +namespace Gateway.Services; + +/// +/// Loads channel provider configuration from the database (tbChannelConfig) +/// instead of appsettings.json. Caches in memory and provides a populated +/// MultiChannelConfig instance for DI consumers. +/// +/// Why: Complex nested JSON in appsettings.json was causing startup crashes +/// with the .NET configuration parser. Database-driven config is also easier +/// to update without redeployment. +/// +/// Usage: +/// - Called once at startup to populate the singleton MultiChannelConfig +/// - Admin endpoints can call RefreshAsync() to reload after DB changes +/// +public sealed class ChannelConfigService +{ + private readonly IServiceProvider _sp; + private readonly IConfiguration _cfg; + private readonly ILogger _log; + private MultiChannelConfig _cached; + private readonly object _lock = new(); + + public ChannelConfigService( + IServiceProvider sp, + IConfiguration cfg, + ILogger log) + { + _sp = sp; + _cfg = cfg; + _log = log; + _cached = BuildDefaults(); + } + + /// + /// The current in-memory config. Always non-null (falls back to defaults). + /// + public MultiChannelConfig Current + { + get { lock (_lock) { return _cached; } } + } + + /// + /// Load channel config from the database. + /// Call at startup and whenever admin updates channel config. + /// + public async Task LoadAsync(CancellationToken ct = default) + { + try + { + using var scope = _sp.CreateScope(); + var sql = scope.ServiceProvider.GetRequiredService(); + + var resp = await sql.ExecProcAsync( + SqlNames.Procs.ChannelConfig, "list", "{}", ct: ct); + + if (string.IsNullOrWhiteSpace(resp)) + { + _log.LogWarning("[ChannelConfig] DB returned empty — using defaults"); + return; + } + + using var doc = JsonDocument.Parse(resp); + var root = doc.RootElement; + + if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean()) + { + _log.LogWarning("[ChannelConfig] DB returned ok=false — using defaults"); + return; + } + + if (!root.TryGetProperty("channels", out var channelsEl) || + channelsEl.ValueKind != JsonValueKind.Array) + { + _log.LogWarning("[ChannelConfig] No channels array in response — using defaults"); + return; + } + + var channels = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var ch in channelsEl.EnumerateArray()) + { + var config = ParseChannel(ch); + if (config != null) + channels[config.ChannelType] = config; + } + + // Build new MultiChannelConfig with DB channels + appsettings allocation + var newConfig = new MultiChannelConfig + { + Channels = channels, + Allocation = LoadAllocationFromConfig() + }; + + lock (_lock) { _cached = newConfig; } + + _log.LogInformation( + "[ChannelConfig] Loaded {Count} channels from DB: {Types}", + channels.Count, + string.Join(", ", channels.Keys)); + } + catch (Exception ex) + { + _log.LogError(ex, "[ChannelConfig] Failed to load from DB — using defaults"); + } + } + + /// Reload config from DB (for admin refresh endpoints). + public Task RefreshAsync(CancellationToken ct = default) => LoadAsync(ct); + + // ──────────────────────────────────────────────── + // Parsing + // ──────────────────────────────────────────────── + + private static ProviderConfig? ParseChannel(JsonElement ch) + { + var channelType = Str(ch, "channelType"); + var displayName = Str(ch, "displayName"); + + if (string.IsNullOrWhiteSpace(channelType) || string.IsNullOrWhiteSpace(displayName)) + return null; + + return new ProviderConfig + { + ChannelType = channelType, + DisplayName = displayName, + Description = Str(ch, "description"), + Icon = Str(ch, "icon"), + Color = Str(ch, "color"), + Enabled = Bool(ch, "enabled", true), + IsStub = Bool(ch, "isStub", true), + Endpoint = Str(ch, "endpoint"), + InternalKey = Str(ch, "internalKey"), + MinDailyBudget = Dec(ch, "minDailyBudget", 5m), + MinMonthlyBudget = Dec(ch, "minMonthlyBudget", 150m), + SupportedObjectives = StringList(ch, "supportedObjectives"), + SupportedCreativeFormats = StringList(ch, "supportedCreativeFormats"), + ApprovalEstimateHours = Int(ch, "approvalEstimateHours", 24), + MetricsRefreshIntervalMinutes = Int(ch, "metricsRefreshIntervalMinutes", 60), + AuthMethod = Str(ch, "authMethod"), + KeyVaultSecretName = Str(ch, "keyVaultSecretName"), + StatusMappings = StringDict(ch, "statusMappings") + }; + } + + // ──────────────────────────────────────────────── + // Allocation (stays in appsettings — simple scalars) + // ──────────────────────────────────────────────── + + private AllocationSettings LoadAllocationFromConfig() + { + var section = _cfg.GetSection("MultiChannel:Allocation"); + if (!section.Exists()) + return new AllocationSettings(); + + return new AllocationSettings + { + MinMultiChannelMonthlyBudget = section.GetValue("MinMultiChannelMonthlyBudget", 500.00m), + MaxChannelsPerInitiative = section.GetValue("MaxChannelsPerInitiative", 5), + DefaultAllocationStrategy = section.GetValue("DefaultAllocationStrategy", "template") ?? "template", + PerformanceEvalIntervalDays = section.GetValue("PerformanceEvalIntervalDays", 7), + PerformanceLookbackDays = section.GetValue("PerformanceLookbackDays", 14), + PerformanceLearningPeriodDays = section.GetValue("PerformanceLearningPeriodDays", 14), + MaxAllocationShiftPct = section.GetValue("MaxAllocationShiftPct", 15.00m), + MinChannelAllocationPct = section.GetValue("MinChannelAllocationPct", 10.00m), + MaxChannelAllocationPct = section.GetValue("MaxChannelAllocationPct", 80.00m) + }; + } + + // ──────────────────────────────────────────────── + // Defaults (used until DB load completes or if DB is unavailable) + // ──────────────────────────────────────────────── + + private MultiChannelConfig BuildDefaults() + { + return new MultiChannelConfig + { + Channels = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["google_ads"] = new() + { + ChannelType = "google_ads", + DisplayName = "Google Ads", + Description = "Search, Display, Shopping & Performance Max across Google properties", + Icon = "google", + Color = "#4285F4", + Enabled = true, + IsStub = false, + MinDailyBudget = 10m, + MinMonthlyBudget = 300m, + SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" }, + SupportedCreativeFormats = new() { "text", "image", "responsive", "video" }, + ApprovalEstimateHours = 24, + AuthMethod = "mcc" + }, + ["meta"] = new() + { + ChannelType = "meta", + DisplayName = "Meta Ads", + Description = "Facebook, Instagram, Messenger & Threads advertising", + Icon = "meta", + Color = "#1877F2", + Enabled = true, + IsStub = true, + MinDailyBudget = 5m, + MinMonthlyBudget = 250m, + SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" }, + SupportedCreativeFormats = new() { "image", "video", "carousel", "stories" }, + ApprovalEstimateHours = 48, + AuthMethod = "oauth2" + }, + ["tiktok"] = new() + { + ChannelType = "tiktok", + DisplayName = "TikTok Ads", + Description = "In-feed video ads across TikTok and partner apps", + Icon = "tiktok", + Color = "#000000", + Enabled = true, + IsStub = true, + MinDailyBudget = 20m, + MinMonthlyBudget = 200m, + SupportedObjectives = new() { "awareness", "traffic", "conversions", "leads", "sales" }, + SupportedCreativeFormats = new() { "video", "image", "spark_ads" }, + ApprovalEstimateHours = 24, + AuthMethod = "oauth2" + } + }, + Allocation = new AllocationSettings() + }; + } + + // ──────────────────────────────────────────────── + // JSON helpers + // ──────────────────────────────────────────────── + + private static string? Str(JsonElement el, string prop) + { + if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String) + return v.GetString(); + return null; + } + + private static bool Bool(JsonElement el, string prop, bool def = false) + { + if (el.TryGetProperty(prop, out var v)) + { + if (v.ValueKind == JsonValueKind.True) return true; + if (v.ValueKind == JsonValueKind.False) return false; + } + return def; + } + + private static int Int(JsonElement el, string prop, int def = 0) + { + if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number) + return v.GetInt32(); + return def; + } + + private static decimal Dec(JsonElement el, string prop, decimal def = 0m) + { + if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number) + return v.GetDecimal(); + return def; + } + + private static List StringList(JsonElement el, string prop) + { + var list = new List(); + if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Array) + { + foreach (var item in v.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + list.Add(item.GetString()!); + } + } + return list; + } + + private static Dictionary StringDict(JsonElement el, string prop) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Object) + { + foreach (var p in v.EnumerateObject()) + { + if (p.Value.ValueKind == JsonValueKind.String) + dict[p.Name] = p.Value.GetString()!; + } + } + return dict; + } +} diff --git a/Gateway/Services/ExecutionService.cs b/Gateway/Services/ExecutionService.cs index 223ecf7..92dbdef 100644 --- a/Gateway/Services/ExecutionService.cs +++ b/Gateway/Services/ExecutionService.cs @@ -11,6 +11,7 @@ public sealed class ExecutionService private readonly IHttpClientFactory _http; private readonly IConfiguration _cfg; private readonly ClientContext _client; + private readonly ImageStorageService _imageStorage; private readonly ILogger _logger; // Operations that don't require a linked account (health checks, etc.) @@ -19,17 +20,31 @@ public sealed class ExecutionService "Ping", "TestPing", "ListAccessibleCustomers" }; + // Providers that require Google Ads account validation + private static readonly HashSet GoogleAccountProviders = new(StringComparer.OrdinalIgnoreCase) + { + "google" + }; + + // Creative operations that return images and need blob storage processing + private static readonly HashSet CreativeImageOperations = new(StringComparer.OrdinalIgnoreCase) + { + "CreateDraft", "GetImages" + }; + public ExecutionService( SqlService sql, IHttpClientFactory http, IConfiguration cfg, ClientContext client, + ImageStorageService imageStorage, ILogger logger) { _sql = sql; _http = http; _cfg = cfg; _client = client; + _imageStorage = imageStorage; _logger = logger; } @@ -46,33 +61,55 @@ public sealed class ExecutionService var service = reqJson.TryGetProperty("service", out var sv) ? sv.GetString() ?? "system" : "system"; var action = reqJson.TryGetProperty("action", out var av) ? av.GetString() ?? "ping" : "ping"; - // Legacy support: if "operation" is provided, use it as action - string? operation = action; + // Operation: explicit "operation" field takes priority, then falls back to "action" + string operation = action; if (reqJson.TryGetProperty("operation", out var opProp) && opProp.ValueKind == JsonValueKind.String) - operation = opProp.GetString(); + operation = opProp.GetString() ?? action; - // TenantId priority: 1) request body, 2) ClientContext, 3) null + // TenantId priority: 1) request body, 2) ClientContext (header), 3) default MCC, 4) null string? tenantId = null; if (reqJson.TryGetProperty("tenantId", out var tid) && tid.ValueKind == JsonValueKind.String) tenantId = tid.GetString(); tenantId ??= _client.TenantId; + // Agency model fallback: use default MCC customer ID if no tenant specified + // This ensures real API calls work even before per-client subaccounts exist + bool tenantIsSystemDefault = false; + if (string.IsNullOrWhiteSpace(tenantId) && GoogleAccountProviders.Contains(provider)) + { + tenantId = _cfg["GoogleAds:DefaultLoginCustomerId"] + ?? _cfg["GOOGLE_DEFAULT_CUSTOMER_ID"] + ?? Environment.GetEnvironmentVariable("GOOGLE_DEFAULT_CUSTOMER_ID"); + + if (!string.IsNullOrWhiteSpace(tenantId)) + { + tenantIsSystemDefault = true; + _logger.LogInformation("[Execution] Using default MCC customer ID as tenantId | RequestId={RequestId}", requestId); + } + } + _logger.LogInformation( - "[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Action={Action} DevBypass={DevBypass}", - requestId, clientId, tenantId, provider, service, action, _client.IsDevBypass); + "[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Operation={Operation} DevBypass={DevBypass}", + requestId, clientId, tenantId, provider, service, operation, _client.IsDevBypass); // ================================================================ - // AGENCY MODEL: Validate account and get loginCustomerId + // AGENCY MODEL: Validate Google account (only for Google provider) + // Skip validation if tenantId is the system-configured MCC default + // (admin pre-configured, not user-supplied) // ================================================================ string? loginCustomerId = null; string? validatedClientName = null; - // Only validate if operation requires a linked account - bool requiresAccount = !string.IsNullOrEmpty(operation) && - !AccountOptionalOperations.Contains(operation) && - !string.IsNullOrEmpty(tenantId); + // Only validate if provider requires it AND operation requires a linked account + // AND tenantId is user-provided (not the system MCC default) + bool requiresGoogleAccount = + GoogleAccountProviders.Contains(provider) && + !string.IsNullOrEmpty(operation) && + !AccountOptionalOperations.Contains(operation) && + !string.IsNullOrEmpty(tenantId) && + !tenantIsSystemDefault; - if (requiresAccount) + if (requiresGoogleAccount) { var validation = await ValidateGoogleAccountAsync(tenantId!, ct); @@ -106,7 +143,7 @@ public sealed class ExecutionService requestId, tenantId, loginCustomerId, validatedClientName); } - // Log start (now includes clientId and routing info) + // Log start (includes routing info) int? logId = null; var startRqst = JsonSerializer.Serialize(new { @@ -116,7 +153,7 @@ public sealed class ExecutionService tenantId, provider, service, - operation = action, + operation, loginCustomerId, sessionId = _client.SessionId, userId = _client.UserId, @@ -131,8 +168,8 @@ public sealed class ExecutionService logId = e.GetInt32(); } - // Inject/override fields in request before forwarding to provider - var enrichedRequest = EnrichRequest(reqJson, requestId, tenantId, loginCustomerId); + // Build enriched request for provider + var enrichedRequest = BuildProviderRequest(reqJson, requestId, operation, tenantId, loginCustomerId); // Forward to provider (URL based on provider type) var sw = Stopwatch.StartNew(); @@ -143,6 +180,11 @@ public sealed class ExecutionService var providerUrl = GetProviderUrl(provider); var key = GetProviderKey(provider); + if (string.IsNullOrWhiteSpace(providerUrl)) + { + throw new InvalidOperationException($"No provider URL configured for '{provider}'. Check environment variables."); + } + var httpClient = _http.CreateClient(); using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute"); msg.Headers.Add("X-Internal-Key", key); @@ -155,17 +197,45 @@ public sealed class ExecutionService } catch (Exception ex) { - _logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId}", requestId); + _logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId} Provider={Provider}", requestId, provider); providerStatus = 500; providerResp = JsonSerializer.Serialize(new { ok = false, requestId, error = ex.Message }); } sw.Stop(); _logger.LogInformation( - "[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Status={Status} ElapsedMs={ElapsedMs}", - requestId, clientId, providerStatus, sw.ElapsedMilliseconds); + "[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Provider={Provider} Status={Status} ElapsedMs={ElapsedMs}", + requestId, clientId, provider, providerStatus, sw.ElapsedMilliseconds); - // Log finish (includes clientId and routing info for correlation) + // ================================================================ + // CREATIVE IMAGE PROCESSING: Store images in blob storage + // ================================================================ + if (provider.Equals("creative", StringComparison.OrdinalIgnoreCase) && + CreativeImageOperations.Contains(operation) && + providerStatus >= 200 && providerStatus < 300 && + _imageStorage.IsConfigured) + { + try + { + _logger.LogInformation( + "[Execution] Processing Creative images | RequestId={RequestId} ClientId={ClientId}", + requestId, clientId); + + providerResp = await _imageStorage.ProcessCreativeDraftAsync( + clientId ?? "unknown", + providerResp, + ct); + } + catch (Exception ex) + { + _logger.LogError(ex, + "[Execution] Image storage failed, returning original response | RequestId={RequestId}", + requestId); + // Continue with original response - non-fatal error + } + } + + // Log finish (includes routing info for correlation) var finishRqst = JsonSerializer.Serialize(new { action = "finish", @@ -174,10 +244,10 @@ public sealed class ExecutionService clientId, provider, service, - operation = action, + operation, providerStatus, elapsedMs = sw.ElapsedMilliseconds, - resp = JsonDocument.Parse(providerResp).RootElement + resp = SafeParseJson(providerResp) }); _ = await _sql.ExecProcAsync("dbo.spAdpApiLog", "finish", finishRqst, ct: ct); @@ -187,6 +257,52 @@ public sealed class ExecutionService return wrappedResponse; } + // ================================================================ + // Provider request building + // ================================================================ + + /// + /// Build a clean request object for the provider container. + /// Ensures "operation" is always set explicitly so providers can dispatch on it. + /// Includes session context so providers know who initiated the request. + /// + private string BuildProviderRequest(JsonElement original, string requestId, string operation, + string? tenantId, string? loginCustomerId) + { + var request = new Dictionary + { + ["requestId"] = requestId, + ["operation"] = operation, + ["tenantId"] = tenantId, + ["loginCustomerId"] = loginCustomerId, + ["session"] = new + { + sessionId = _client.SessionId, + clientId = _client.ClientId, + userId = _client.UserId, + isDevBypass = _client.IsDevBypass + } + }; + + // Copy payload if present (provider-specific data) + if (original.TryGetProperty("payload", out var payload)) + { + request["payload"] = payload; + } + + // Copy service/action for providers that use them + if (original.TryGetProperty("service", out var svc)) + request["service"] = svc.GetString(); + if (original.TryGetProperty("action", out var act)) + request["action"] = act.GetString(); + + return JsonSerializer.Serialize(request); + } + + // ================================================================ + // Account validation (Google-specific) + // ================================================================ + /// /// Validate that a Google Ads customer ID is linked in the database. /// Returns loginCustomerId if account is found. @@ -258,36 +374,19 @@ public sealed class ExecutionService } } - /// - /// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider. - /// - private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId) - { - using var doc = JsonDocument.Parse(original.GetRawText()); - var dict = JsonSerializer.Deserialize>(doc.RootElement.GetRawText()) - ?? new Dictionary(); - - // Add/override requestId - dict["requestId"] = JsonDocument.Parse($"\"{requestId}\"").RootElement; - - // Add tenantId if we have one - if (!string.IsNullOrWhiteSpace(tenantId)) - { - dict["tenantId"] = JsonDocument.Parse($"\"{tenantId}\"").RootElement; - } - - // Add loginCustomerId (manager account) if we have one - if (!string.IsNullOrWhiteSpace(loginCustomerId)) - { - dict["loginCustomerId"] = JsonDocument.Parse($"\"{loginCustomerId}\"").RootElement; - } - - return JsonSerializer.Serialize(dict); - } + // ================================================================ + // Response wrapping + // ================================================================ /// /// Wrap provider response with Gateway metadata. /// + private static object SafeParseJson(string raw) + { + try { return JsonDocument.Parse(raw).RootElement; } + catch { return raw[..Math.Min(raw.Length, 500)]; } + } + private static string WrapResponse(string providerResp, int status, long elapsedMs, string requestId, string? clientId) { try @@ -319,9 +418,10 @@ public sealed class ExecutionService } } - /// - /// Result of account validation. - /// + // ================================================================ + // Validation result + // ================================================================ + private sealed class AccountValidation { public bool IsValid { get; init; } @@ -345,6 +445,10 @@ public sealed class ExecutionService }; } + // ================================================================ + // Provider routing + // ================================================================ + /// /// Get provider URL based on provider type. /// @@ -353,9 +457,12 @@ public sealed class ExecutionService return provider.ToLowerInvariant() switch { "google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "", + "creative" => _cfg["CREATIVE_PROVIDER_URL"]?.TrimEnd('/') ?? "", "meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "", + "tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "", + "intelligence" => _cfg["INTELLIGENCE_API_URL"]?.TrimEnd('/') ?? "", "msads" => _cfg["MSADS_PROVIDER_URL"]?.TrimEnd('/') ?? "", - _ => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "" + _ => "" // No default fallback ? unknown providers fail explicitly }; } @@ -367,9 +474,12 @@ public sealed class ExecutionService return provider.ToLowerInvariant() switch { "google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "", + "creative" => _cfg["CREATIVE_INTERNAL_KEY"] ?? "", "meta" => _cfg["META_INTERNAL_KEY"] ?? "", + "tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "", + "intelligence" => _cfg["INTELLIGENCE_INTERNAL_KEY"] ?? "", "msads" => _cfg["MSADS_INTERNAL_KEY"] ?? "", - _ => _cfg["GOOGLE_INTERNAL_KEY"] ?? "" + _ => "" }; } } \ No newline at end of file diff --git a/Gateway/Services/ForecastService.cs b/Gateway/Services/ForecastService.cs new file mode 100644 index 0000000..063b8ca --- /dev/null +++ b/Gateway/Services/ForecastService.cs @@ -0,0 +1,478 @@ +using Gateway.Models; +using System.Diagnostics; +using System.Text.Json; + +namespace Gateway.Services; + +/// +/// Fans out to provider services for forecast estimates, normalizes the responses, +/// scores them by objective, and derives recommended allocation percentages. +/// +/// Called by ForecastController for the wizard budget step. +/// Same capability can serve admin seed workflow later. +/// +public sealed class ForecastService +{ + private readonly IHttpClientFactory _http; + private readonly IConfiguration _cfg; + private readonly ILogger _logger; + + private const int MIN_ALLOCATION = 15; + private const int MAX_ALLOCATION = 85; + + public ForecastService(IHttpClientFactory http, IConfiguration cfg, ILogger logger) + { + _http = http; + _cfg = cfg; + _logger = logger; + } + + /// + /// Generate forecast estimates across requested channels and return + /// normalized comparison with recommended allocation. + /// + public async Task ForecastAsync(ChannelForecastRequest request, CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + var channels = request.Channels ?? new List { "google_ads" }; + var weights = ObjectiveWeights.For(request.Objective); + + _logger.LogInformation( + "[Forecast] Starting | Objective={Obj} Budget={Budget} Channels={Ch}", + request.Objective, request.MonthlyBudget, string.Join(",", channels)); + + // ── Fan out to providers in parallel ── + var tasks = new Dictionary>(); + foreach (var channel in channels) + { + tasks[channel] = channel switch + { + "google_ads" => FetchGoogleForecastAsync(request, ct), + "meta" => FetchMetaForecastAsync(request, ct), + "tiktok" => Task.FromResult(TemplateForecast("tiktok", request.MonthlyBudget, channels.Count)), + _ => Task.FromResult(TemplateForecast(channel, request.MonthlyBudget, channels.Count)) + }; + } + + await Task.WhenAll(tasks.Values); + + // ── Collect results ── + var results = new Dictionary(); + foreach (var (channel, task) in tasks) + { + results[channel] = task.Result; + } + + // ── Score and derive allocation ── + var scored = ScoreChannels(results, weights); + var allocations = DeriveAllocations(scored); + + // ── Build response ── + var channelEstimates = new List(); + foreach (var (channel, result) in results) + { + var pct = allocations[channel]; + var allocated = Math.Round(request.MonthlyBudget * pct / 100m, 2); + + channelEstimates.Add(new ChannelEstimate + { + Provider = channel, + AllocationPercent = pct, + AllocatedBudget = allocated, + Estimates = new ChannelEstimateMetrics + { + Impressions = result.Impressions, + Reach = result.Reach, + Clicks = result.Clicks, + Conversions = result.Conversions, + AvgCpc = result.AvgCpc, + AvgCpm = result.AvgCpm, + EstimatedCpa = result.EstimatedCpa, + Ctr = result.Ctr + }, + EfficiencyScore = Math.Round(scored[channel], 3), + StrengthLabel = GetStrengthLabel(channel, request.Objective), + Confidence = result.Confidence, + DataSource = result.DataSource + }); + } + + // Sort by allocation descending + channelEstimates.Sort((a, b) => b.AllocationPercent.CompareTo(a.AllocationPercent)); + + sw.Stop(); + _logger.LogInformation("[Forecast] Complete | Elapsed={Ms}ms", sw.ElapsedMilliseconds); + + return new ChannelForecastResponse + { + Ok = true, + Objective = request.Objective, + TotalBudget = request.MonthlyBudget, + Channels = channelEstimates, + Recommendation = BuildRecommendation(channelEstimates, request.Objective), + Metadata = new ForecastMeta + { + GeneratedAt = DateTimeOffset.UtcNow, + ForecastPeriod = "30 days" + } + }; + } + + // ════════════════════════════════════════════════ + // Provider calls + // ════════════════════════════════════════════════ + + private async Task FetchGoogleForecastAsync( + ChannelForecastRequest request, CancellationToken ct) + { + try + { + var providerUrl = _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? ""; + var key = _cfg["GOOGLE_INTERNAL_KEY"] ?? ""; + + if (string.IsNullOrWhiteSpace(providerUrl)) + return EmulatedGoogleFallback(request); + + // Build provider request matching existing ProviderRequest pattern + var payload = new + { + keywords = request.Keywords, + geoTargetIds = request.GeoTargeting?.GeoTargetIds ?? new List(), + monthlyBudget = request.MonthlyBudget, + currencyCode = "USD", + forecastDays = 30 + }; + + var providerRequest = new + { + operation = "KeywordForecast", + requestId = Guid.NewGuid().ToString("N"), + payload + }; + + var httpClient = _http.CreateClient(); + using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute"); + msg.Headers.Add("X-Internal-Key", key); + msg.Content = new StringContent( + JsonSerializer.Serialize(providerRequest, _jsonOpts), + System.Text.Encoding.UTF8, "application/json"); + + using var resp = await httpClient.SendAsync(msg, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("[Forecast] Google provider returned {Status}", (int)resp.StatusCode); + return EmulatedGoogleFallback(request); + } + + // Parse provider response: { ok, data: { provider, monthly, metrics, ... } } + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + var data = root.TryGetProperty("data", out var d) ? d : root; + + var monthly = data.GetProperty("monthly"); + var metrics = data.GetProperty("metrics"); + + return new ProviderForecastResult + { + Provider = "google_ads", + Impressions = monthly.TryGetProperty("impressions", out var imp) ? imp.GetDouble() : 0, + Clicks = monthly.TryGetProperty("clicks", out var cl) ? cl.GetDouble() : 0, + Conversions = monthly.TryGetProperty("conversions", out var conv) ? conv.GetDouble() : 0, + Reach = null, // Google Search doesn't provide reach + AvgCpc = metrics.TryGetProperty("avgCpc", out var cpc) ? cpc.GetDecimal() : 0, + AvgCpm = metrics.TryGetProperty("avgCpm", out var cpm) ? cpm.GetDecimal() : 0, + Ctr = metrics.TryGetProperty("ctr", out var ctr) ? ctr.GetDouble() : 0, + EstimatedCpa = metrics.TryGetProperty("estimatedCpa", out var cpa) && cpa.ValueKind != JsonValueKind.Null + ? cpa.GetDecimal() : null, + Confidence = data.TryGetProperty("confidence", out var cf) ? cf.GetString() ?? "low" : "low", + DataSource = data.TryGetProperty("dataSource", out var ds) ? ds.GetString() ?? "emulated" : "emulated" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "[Forecast] Google provider call failed"); + return EmulatedGoogleFallback(request); + } + } + + private async Task FetchMetaForecastAsync( + ChannelForecastRequest request, CancellationToken ct) + { + // TODO Phase 2: Call MetaApi /internal/execute with DeliveryEstimate operation + // For now, return realistic emulated Meta estimates + await Task.CompletedTask; + + var budget = request.MonthlyBudget; + var rng = new Random((int)(budget * 77)); + var v = 0.85 + (rng.NextDouble() * 0.30); + + // Meta: strong reach/impressions, moderate clicks, lower CPC than Google + var cpm = 12.50m + (decimal)(rng.NextDouble() * 8.0); // $12.50 – $20.50 + var impressions = budget > 0 ? (double)(budget / cpm) * 1000 * v : 0; + var reach = impressions * 0.42; // ~2.4 frequency + var clickRate = 0.012 + (rng.NextDouble() * 0.008); // 1.2% – 2.0% CTR + var clicks = impressions * clickRate; + var convRate = 0.025 + (rng.NextDouble() * 0.015); + var conversions = clicks * convRate; + var avgCpc = clicks > 0 ? budget / (decimal)clicks : 0; + var cpa = conversions > 0 ? budget / (decimal)conversions : (decimal?)null; + + return new ProviderForecastResult + { + Provider = "meta", + Impressions = Math.Round(impressions), + Clicks = Math.Round(clicks), + Conversions = Math.Round(conversions, 1), + Reach = Math.Round(reach), + AvgCpc = Math.Round(avgCpc, 2), + AvgCpm = Math.Round(cpm, 2), + Ctr = Math.Round(clickRate, 4), + EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null, + Confidence = "low", + DataSource = "emulated" + }; + } + + /// Template-only fallback for channels without API forecasting (e.g., TikTok) + private static ProviderForecastResult TemplateForecast(string provider, decimal totalBudget, int channelCount) + { + return new ProviderForecastResult + { + Provider = provider, + Confidence = "none", + DataSource = "template" + }; + } + + /// Client-side Google emulation when provider is unreachable + private ProviderForecastResult EmulatedGoogleFallback(ChannelForecastRequest request) + { + var budget = request.MonthlyBudget; + var kwCount = Math.Max(request.Keywords.Count, 1); + var rng = new Random((int)(budget * 100) + kwCount); + var v = 0.85 + (rng.NextDouble() * 0.30); + + var baseCpc = 2.50m - (decimal)(Math.Min(kwCount, 20) / 20.0 * 1.20); + var clicks = budget > 0 ? (double)(budget / baseCpc) * v : 0; + var impressions = clicks / 0.045; + var conversions = clicks * 0.035; + var ctr = impressions > 0 ? clicks / impressions : 0; + var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0; + var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null; + + return new ProviderForecastResult + { + Provider = "google_ads", + Impressions = Math.Round(impressions), + Clicks = Math.Round(clicks), + Conversions = Math.Round(conversions, 1), + AvgCpc = Math.Round(baseCpc, 2), + AvgCpm = Math.Round((decimal)cpm, 2), + Ctr = Math.Round(ctr, 4), + EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null, + Confidence = "low", + DataSource = "emulated" + }; + } + + // ════════════════════════════════════════════════ + // Scoring: objective-weighted efficiency + // ════════════════════════════════════════════════ + + private static Dictionary ScoreChannels( + Dictionary results, MetricWeights w) + { + // Only score channels that have real estimates + var scoreable = results + .Where(r => r.Value.DataSource != "template") + .ToDictionary(r => r.Key, r => r.Value); + + if (scoreable.Count == 0) + return results.ToDictionary(r => r.Key, _ => 1.0); + + // For each "more is better" metric: normalize to 0–1 (best = 1.0) + // For each "less is better" metric: invert (lowest = 1.0) + //double Norm(Func selector, bool invert = false) + //{ + // Not used directly — we normalize per-channel below + // return 0; + // } + + var scores = new Dictionary(); + + // Find max/min across scoreable channels for normalization + var maxImp = scoreable.Values.Max(r => r.Impressions); + var maxReach = scoreable.Values.Max(r => r.Reach ?? 0); + var maxClicks = scoreable.Values.Max(r => r.Clicks); + var maxConv = scoreable.Values.Max(r => r.Conversions); + var maxCtr = scoreable.Values.Max(r => r.Ctr); + + var minCpm = scoreable.Values.Where(r => r.AvgCpm > 0).Select(r => r.AvgCpm).DefaultIfEmpty(1).Min(); + var minCpc = scoreable.Values.Where(r => r.AvgCpc > 0).Select(r => r.AvgCpc).DefaultIfEmpty(1).Min(); + var minCpa = scoreable.Values.Where(r => r.EstimatedCpa > 0).Select(r => r.EstimatedCpa!.Value).DefaultIfEmpty(1).Min(); + + foreach (var (channel, r) in scoreable) + { + double score = 0; + + // "More is better" — value / max + score += w.Impressions * SafeDiv(r.Impressions, maxImp); + score += w.Reach * SafeDiv(r.Reach ?? 0, maxReach > 0 ? maxReach : 1); + score += w.Clicks * SafeDiv(r.Clicks, maxClicks); + score += w.Conversions * SafeDiv(r.Conversions, maxConv); + score += w.Ctr * SafeDiv(r.Ctr, maxCtr); + + // "Less is better" — min / value + score += w.Cpm * (r.AvgCpm > 0 ? (double)(minCpm / r.AvgCpm) : 0); + score += w.Cpc * (r.AvgCpc > 0 ? (double)(minCpc / r.AvgCpc) : 0); + score += w.Cpa * (r.EstimatedCpa > 0 ? (double)(minCpa / r.EstimatedCpa!.Value) : 0); + + scores[channel] = score; + } + + // Template-only channels get the average score + var avgScore = scores.Values.Average(); + foreach (var channel in results.Keys.Except(scoreable.Keys)) + { + scores[channel] = avgScore * 0.5; // Slight penalty for no data + } + + return scores; + } + + private static Dictionary DeriveAllocations(Dictionary scores) + { + var total = scores.Values.Sum(); + if (total == 0) + { + // Even split + var even = 100 / scores.Count; + return scores.ToDictionary(s => s.Key, _ => even); + } + + // Proportional split + var raw = scores.ToDictionary(s => s.Key, s => (int)Math.Round(s.Value / total * 100)); + + // Apply floor/ceiling constraints + foreach (var key in raw.Keys.ToList()) + { + raw[key] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[key])); + } + + // Normalize to exactly 100% + var sum = raw.Values.Sum(); + if (sum != 100 && raw.Count > 0) + { + var diff = 100 - sum; + // Add/subtract difference from the highest-scored channel + var topChannel = raw.OrderByDescending(r => r.Value).First().Key; + raw[topChannel] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[topChannel] + diff)); + } + + return raw; + } + + // ════════════════════════════════════════════════ + // Helpers + // ════════════════════════════════════════════════ + + private static double SafeDiv(double numerator, double denominator) + => denominator > 0 ? numerator / denominator : 0; + + private static string GetStrengthLabel(string channel, string objective) => channel switch + { + "google_ads" => objective switch + { + "awareness" => "Strong for search visibility", + "traffic" => "Strong for search intent clicks", + "leads" => "Strong for high-intent leads", + "sales" => "Strong for purchase intent", + _ => "Search & intent targeting" + }, + "meta" => objective switch + { + "awareness" => "Strong for reach & discovery", + "traffic" => "Strong for social traffic", + "leads" => "Strong for lead gen forms", + "sales" => "Strong for retargeting & social proof", + _ => "Social reach & engagement" + }, + "tiktok" => objective switch + { + "awareness" => "Strong for viral reach", + _ => "Video-first engagement" + }, + _ => "Advertising channel" + }; + + private static ForecastRecommendation BuildRecommendation( + List channels, string objective) + { + if (channels.Count < 2) + return new ForecastRecommendation + { + Summary = $"Budget allocated to {channels.FirstOrDefault()?.Provider ?? "channel"} for {objective}.", + Highlights = new List() + }; + + var top = channels[0]; + var second = channels[1]; + var highlights = new List(); + + // Compare key metrics + if (top.Estimates.Clicks > 0 && second.Estimates.Clicks > 0) + { + var clickRatio = top.Estimates.Clicks / second.Estimates.Clicks; + if (clickRatio > 1.3) + highlights.Add($"{ChannelDisplayName(top.Provider)}: ~{clickRatio:F0}x more clicks per dollar"); + } + + if (top.Estimates.Impressions > 0 && second.Estimates.Impressions > 0) + { + var impRatio = second.Estimates.Impressions / top.Estimates.Impressions; + if (impRatio > 1.5) + highlights.Add($"{ChannelDisplayName(second.Provider)}: ~{impRatio:F0}x more impressions per dollar"); + } + + if (top.Estimates.EstimatedCpa > 0 && second.Estimates.EstimatedCpa > 0) + { + highlights.Add($"CPA range: ${Math.Min(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0}–${Math.Max(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0} across channels"); + } + + return new ForecastRecommendation + { + Summary = $"Recommended {top.AllocationPercent}/{second.AllocationPercent} split " + + $"between {ChannelDisplayName(top.Provider)} and {ChannelDisplayName(second.Provider)}, " + + $"optimized for {objective}.", + Highlights = highlights + }; + } + + private static string ChannelDisplayName(string provider) => provider switch + { + "google_ads" => "Google", + "meta" => "Meta", + "tiktok" => "TikTok", + _ => provider + }; + + private static readonly JsonSerializerOptions _jsonOpts = new(JsonSerializerDefaults.Web); + + /// Internal result from a single provider call + private sealed class ProviderForecastResult + { + public string Provider { get; set; } = string.Empty; + public double Impressions { get; set; } + public double? Reach { get; set; } + public double Clicks { get; set; } + public double Conversions { get; set; } + public decimal AvgCpc { get; set; } + public decimal AvgCpm { get; set; } + public double Ctr { get; set; } + public decimal? EstimatedCpa { get; set; } + public string Confidence { get; set; } = "none"; + public string DataSource { get; set; } = "none"; + } +} diff --git a/Gateway/Services/ImageStorageService.cs b/Gateway/Services/ImageStorageService.cs new file mode 100644 index 0000000..95e1588 --- /dev/null +++ b/Gateway/Services/ImageStorageService.cs @@ -0,0 +1,353 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using System.Text.Json; + +namespace Gateway.Services; + +/// +/// Handles downloading images from source URLs and storing them in Azure Blob Storage. +/// Used when processing Creative drafts to ensure all image URLs are permanent. +/// +/// Blob structure: {clientId}/drafts/{draftId}/{orientation}.{ext} +/// Example: client-42/drafts/a1b2c3d4e5f6/landscape.jpg +/// +/// This structure enables: +/// - Client isolation (easy to list/delete all client assets) +/// - Draft organization (images grouped per draft) +/// - Future expansion (campaigns, versions, etc.) +/// - Per-client access control via SAS tokens if needed +/// +public class ImageStorageService +{ + private readonly BlobServiceClient _blobClient; + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _logger; + private readonly string _containerName; + private readonly string _blobBaseUrl; + private readonly bool _isConfigured; + + public ImageStorageService( + IHttpClientFactory httpFactory, + ILogger logger, + IConfiguration config, + BlobServiceClient blobClient) + { + _httpFactory = httpFactory; + _logger = logger; + _blobClient = blobClient; + _containerName = config["BlobStorage:ContainerName"] ?? "creative-images"; + _blobBaseUrl = config["BlobStorage:BaseUrl"] ?? "https://usimadpcreatives.blob.core.windows.net"; + _isConfigured = blobClient != null; + + if (!_isConfigured) + { + _logger.LogWarning("[ImageStorage] Blob storage not configured - images will use source URLs"); + } + else + { + _logger.LogInformation("[ImageStorage] Blob storage configured: {BaseUrl}/{Container}", _blobBaseUrl, _containerName); + } + } + + /// + /// Whether blob storage is configured and available. + /// + public bool IsConfigured => _isConfigured; + + /// + /// Process a Creative draft response, downloading and storing images in blob storage. + /// Returns the modified JSON with blob URLs replacing source URLs. + /// + public async Task ProcessCreativeDraftAsync( + string clientId, + string providerResponseJson, + CancellationToken ct) + { + if (!_isConfigured) + { + _logger.LogDebug("[ImageStorage] Skipping image processing - not configured"); + return providerResponseJson; + } + + try + { + using var doc = JsonDocument.Parse(providerResponseJson); + var root = doc.RootElement; + + // Check if this is a successful response with data + if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean()) + return providerResponseJson; + + if (!root.TryGetProperty("data", out var dataProp)) + return providerResponseJson; + + // Check if data has images array + if (!dataProp.TryGetProperty("images", out var imagesProp) || + imagesProp.ValueKind != JsonValueKind.Array || + imagesProp.GetArrayLength() == 0) + { + _logger.LogDebug("[ImageStorage] No images in draft response"); + return providerResponseJson; + } + + // Get draftId + var draftId = dataProp.TryGetProperty("draftId", out var draftIdProp) + ? draftIdProp.GetString() ?? Guid.NewGuid().ToString("N")[..12] + : Guid.NewGuid().ToString("N")[..12]; + + _logger.LogInformation( + "[ImageStorage] Processing {Count} images for client {ClientId} draft {DraftId}", + imagesProp.GetArrayLength(), clientId, draftId); + + // Ensure container exists + var containerClient = _blobClient.GetBlobContainerClient(_containerName); + await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: ct); + + // Process each image and collect results + var processedImages = new List>(); + + foreach (var image in imagesProp.EnumerateArray()) + { + var processedImage = await ProcessSingleImageAsync( + containerClient, clientId, draftId, image, ct); + processedImages.Add(processedImage); + } + + // Rebuild the response with updated image URLs + return RebuildResponseWithProcessedImages(doc, processedImages); + } + catch (Exception ex) + { + _logger.LogError(ex, "[ImageStorage] Failed to process draft images, returning original response"); + return providerResponseJson; + } + } + + /// + /// Process a single image: download and upload to blob storage. + /// + private async Task> ProcessSingleImageAsync( + BlobContainerClient container, + string clientId, + string draftId, + JsonElement image, + CancellationToken ct) + { + // Extract image properties + var imageId = image.TryGetProperty("imageId", out var idProp) ? idProp.GetString() : null; + var sourceUrl = image.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null; + var downloadUrl = image.TryGetProperty("downloadUrl", out var dlProp) ? dlProp.GetString() : null; + var orientation = image.TryGetProperty("orientation", out var orProp) ? orProp.GetString() ?? "unknown" : "unknown"; + var source = image.TryGetProperty("source", out var srcProp) ? srcProp.GetString() : "unknown"; + var width = image.TryGetProperty("width", out var wProp) ? wProp.GetInt32() : 0; + var height = image.TryGetProperty("height", out var hProp) ? hProp.GetInt32() : 0; + var altText = image.TryGetProperty("altText", out var altProp) ? altProp.GetString() : null; + var attribution = image.TryGetProperty("attribution", out var attrProp) ? attrProp.GetString() : null; + + // Build result with original properties + var result = new Dictionary + { + ["imageId"] = imageId, + ["url"] = sourceUrl, // Will be replaced with blob URL on success + ["source"] = source, + ["orientation"] = orientation, + ["width"] = width, + ["height"] = height, + ["altText"] = altText, + ["attribution"] = attribution, + ["downloadUrl"] = downloadUrl, + ["blobStored"] = false // Track whether we stored it + }; + + if (string.IsNullOrEmpty(sourceUrl)) + { + _logger.LogWarning("[ImageStorage] Image has no URL, skipping"); + return result; + } + + try + { + // Download image bytes (prefer download URL for higher quality) + var fetchUrl = !string.IsNullOrEmpty(downloadUrl) ? downloadUrl : sourceUrl; + + var httpClient = _httpFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(30); + + _logger.LogDebug("[ImageStorage] Downloading from {Url}", fetchUrl); + + using var response = await httpClient.GetAsync(fetchUrl, ct); + response.EnsureSuccessStatusCode(); + + var contentType = response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"; + var extension = GetExtensionFromContentType(contentType); + + // Build blob path: {clientId}/drafts/{draftId}/{orientation}.{ext} + var blobName = $"{clientId}/drafts/{draftId}/{orientation}.{extension}"; + var blobClient = container.GetBlobClient(blobName); + + // Upload with proper content type and caching headers + await using var stream = await response.Content.ReadAsStreamAsync(ct); + + var uploadOptions = new BlobUploadOptions + { + HttpHeaders = new BlobHttpHeaders + { + ContentType = contentType, + CacheControl = "public, max-age=31536000" // 1 year cache + }, + Metadata = new Dictionary + { + ["source"] = source ?? "unknown", + ["originalUrl"] = sourceUrl, + ["orientation"] = orientation, + ["width"] = width.ToString(), + ["height"] = height.ToString(), + ["clientId"] = clientId, + ["draftId"] = draftId + } + }; + + await blobClient.UploadAsync(stream, uploadOptions, ct); + + // Build permanent blob URL + var blobUrl = $"{_blobBaseUrl}/{_containerName}/{blobName}"; + + result["url"] = blobUrl; + result["blobStored"] = true; + result["originalUrl"] = sourceUrl; // Keep original for reference + + _logger.LogInformation("[ImageStorage] Stored {Orientation} image: {BlobUrl}", orientation, blobUrl); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[ImageStorage] Failed to store {Orientation} image, keeping original URL", orientation); + // Keep original URL as fallback + } + + return result; + } + + /// + /// Rebuild the provider response JSON with processed image data. + /// + private static string RebuildResponseWithProcessedImages( + JsonDocument original, + List> processedImages) + { + var root = original.RootElement; + + // Build new response maintaining structure + var response = new Dictionary + { + ["ok"] = root.GetProperty("ok").GetBoolean(), + ["requestId"] = root.TryGetProperty("requestId", out var rid) ? rid.GetString() : null + }; + + // Rebuild data object with processed images + if (root.TryGetProperty("data", out var dataProp)) + { + var data = new Dictionary(); + + // Copy all data properties except images + foreach (var prop in dataProp.EnumerateObject()) + { + if (prop.Name == "images") + continue; + + data[prop.Name] = JsonElementToObject(prop.Value); + } + + // Add processed images + data["images"] = processedImages; + + response["data"] = data; + } + + // Copy error if present + if (root.TryGetProperty("error", out var errorProp)) + { + response["error"] = JsonElementToObject(errorProp); + } + + return JsonSerializer.Serialize(response, new JsonSerializerOptions + { + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + } + + /// + /// Convert JsonElement to object for serialization. + /// + private static object? JsonElementToObject(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Object => element.EnumerateObject() + .ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)), + JsonValueKind.Array => element.EnumerateArray() + .Select(JsonElementToObject).ToList(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => null + }; + } + + /// + /// Delete all images for a specific draft. + /// + public async Task DeleteDraftImagesAsync(string clientId, string draftId, CancellationToken ct) + { + if (!_isConfigured) return; + + var containerClient = _blobClient.GetBlobContainerClient(_containerName); + + var prefix = $"{clientId}/drafts/{draftId}/"; + await foreach (var blob in containerClient.GetBlobsAsync( + traits: BlobTraits.None, + states: BlobStates.None, + prefix: prefix, + cancellationToken: ct)) + { + await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct); + _logger.LogInformation("[ImageStorage] Deleted blob {Name}", blob.Name); + } + } + + /// + /// Delete all images for a client. + /// + public async Task DeleteClientImagesAsync(string clientId, CancellationToken ct) + { + if (!_isConfigured) return; + + var containerClient = _blobClient.GetBlobContainerClient(_containerName); + + var prefix = $"{clientId}/"; + var count = 0; + + await foreach (var blob in containerClient.GetBlobsAsync( + traits: BlobTraits.None, + states: BlobStates.None, + prefix: prefix, + cancellationToken: ct)) + { + await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct); + count++; + } + + _logger.LogInformation("[ImageStorage] Deleted {Count} blobs for client {ClientId}", count, clientId); + } + + private static string GetExtensionFromContentType(string contentType) => contentType switch + { + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + _ => "jpg" + }; +} diff --git a/Gateway/Services/InitiativeLaunchService.cs b/Gateway/Services/InitiativeLaunchService.cs new file mode 100644 index 0000000..604d638 --- /dev/null +++ b/Gateway/Services/InitiativeLaunchService.cs @@ -0,0 +1,553 @@ +using Gateway.Data; +using Gateway.Models; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace Gateway.Services; + +/// +/// Orchestrates launching an initiative by dispatching each channel campaign +/// to its provider service (GoogleApi, Meta, TikTok, etc.). +/// +/// Flow: +/// 1. Load initiative + channel campaigns from DB (single call, channels nested) +/// 2. Validate initiative belongs to requesting client +/// 3. For each channel in "pending" status: +/// a. Resolve provider config (endpoint, stub status) +/// b. If real provider → dispatch via ExecutionService +/// c. If stub/unconfigured → simulate "pending_review" +/// d. Sync result back to DB via spChannelCampaign +/// 4. Update initiative status based on aggregate results +/// +public sealed class InitiativeLaunchService +{ + private readonly SqlService _sql; + private readonly ExecutionService _execution; + private readonly MultiChannelConfig _config; + private readonly ProviderStatusNormalizer _statusNorm; + private readonly IConfiguration _appConfig; + private readonly ILogger _log; + + public InitiativeLaunchService( + SqlService sql, + ExecutionService execution, + IOptions config, + ProviderStatusNormalizer statusNorm, + IConfiguration appConfig, + ILogger log) + { + _sql = sql; + _execution = execution; + _config = config.Value; + _statusNorm = statusNorm; + _appConfig = appConfig; + _log = log; + } + + /// + /// Launch all pending channel campaigns for an initiative. + /// Returns a per-channel result summary. + /// + public async Task LaunchAsync( + long initiativeId, + string clientId, + string? userId, + CancellationToken ct) + { + _log.LogInformation("[Launch] Starting initiative {InitiativeId} for client {ClientId}", + initiativeId, clientId); + + var result = new LaunchResult { InitiativeId = initiativeId }; + + // 1. Get initiative + nested channels in a single call + // Pass clientId for ownership validation + var initResp = await _sql.ExecProcAsync( + SqlNames.Procs.Initiative, "get", + JsonSerializer.Serialize(new { initiativeId, clientId }), ct: ct); + + if (string.IsNullOrWhiteSpace(initResp)) + { + result.Error = "Initiative not found"; + return result; + } + + using var initDoc = JsonDocument.Parse(initResp); + var initRoot = initDoc.RootElement; + + if (initRoot.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean()) + { + result.Error = initRoot.TryGetProperty("error", out var errProp) + ? errProp.GetString() ?? "Initiative not found" + : "Initiative not found"; + return result; + } + + // Extract initiative fields we need for dispatch (check both clean and prefixed shapes) + var initiative = initRoot.TryGetProperty("initiative", out var initEl) ? initEl : initRoot; + var initiativeName = TryStr(initiative, "name", "iniName") ?? "Campaign"; + var objective = TryStr(initiative, "objective", "iniObjective") ?? "traffic"; + var totalBudget = TryDec(initiative, "totalBudget", "iniBudget"); + var budgetPeriod = TryStr(initiative, "budgetPeriod", "iniBudgetPeriod") ?? "monthly"; + var businessCategory = TryStr(initiative, "businessCategory", "iniBusinessCategory"); + + // 2. Extract channels from the initiative response (already nested by spInitiative 'get') + // No separate DB call needed — channels come back with clean field names + JsonElement campaignsArray; + if (initiative.TryGetProperty("channels", out var channelsEl) && channelsEl.ValueKind == JsonValueKind.Array) + { + campaignsArray = channelsEl; + } + else + { + _log.LogWarning("[Launch] No channels array in initiative response. Keys: {Keys}", + string.Join(", ", EnumerateKeys(initiative))); + result.Error = "No channel campaigns found for this initiative"; + return result; + } + + if (campaignsArray.GetArrayLength() == 0) + { + result.Error = "Initiative has no channel campaigns to launch"; + return result; + } + + _log.LogInformation("[Launch] Found {Count} channel campaigns for initiative {InitiativeId}", + campaignsArray.GetArrayLength(), initiativeId); + + // 3. Dispatch each channel campaign + foreach (var camp in campaignsArray.EnumerateArray()) + { + var channelResult = new ChannelLaunchResult(); + + // Fields come back with clean names from spInitiative 'get': + // channelCampaignId, channelType, allocatedBudget, allocationPct, + // externalCampaignId, externalAccountId, providerPayload, status, providerStatus + var ccId = TryLong(camp, "channelCampaignId", "chcId"); + var channelType = TryStr(camp, "channelType", "chcChannelType") ?? "unknown"; + var status = TryStr(camp, "status", "chcStatus") ?? "pending"; + var allocationPct = TryDec(camp, "allocationPct", "chcAllocationPct"); + if (allocationPct == 0m) allocationPct = 100m; + + channelResult.ChannelCampaignId = ccId; + channelResult.ChannelType = channelType ?? "unknown"; + + // Skip already-launched channels + if (status != "pending" && status != "draft" && status != "staged") + { + channelResult.Status = status ?? "unknown"; + channelResult.Message = "Already dispatched"; + channelResult.Skipped = true; + result.Channels.Add(channelResult); + continue; + } + + // Calculate this channel's budget + var channelBudget = totalBudget * allocationPct / 100m; + + // Look up provider config + var providerConfig = _config.GetChannel(channelType ?? ""); + + if (providerConfig == null || !providerConfig.Enabled) + { + channelResult.Status = "error"; + channelResult.Message = $"Channel '{channelType}' is not enabled"; + result.Channels.Add(channelResult); + continue; + } + + try + { + // Determine if a real provider is available: + // Check both MultiChannel config Endpoint AND the PROVIDER_URL env var + // that ExecutionService uses for routing. + var hasRealProvider = !providerConfig.IsStub && IsProviderUrlConfigured(channelType!); + + if (!hasRealProvider) + { + // Stub provider - simulate submission + channelResult = await DispatchStubAsync(ccId, channelType!, providerConfig, ct); + } + else + { + // Real provider - dispatch through ExecutionService + channelResult = await DispatchRealAsync( + ccId, channelType!, providerConfig, + initiativeName, objective, channelBudget, budgetPeriod, + businessCategory, clientId, ct); + } + } + catch (Exception ex) + { + _log.LogError(ex, "[Launch] Dispatch failed for channel {Channel} on initiative {InitiativeId}", + channelType, initiativeId); + channelResult.Status = "error"; + channelResult.Message = $"Dispatch error: {ex.Message}"; + } + + result.Channels.Add(channelResult); + } + + // 4. Update initiative status based on results + var anySuccess = result.Channels.Any(c => + c.Status == "active" || c.Status == "pending" || c.Status == "submitted"); + var allFailed = result.Channels.All(c => c.Status == "error"); + + if (anySuccess) + { + await UpdateInitiativeStatus(initiativeId, "active", ct); + result.InitiativeStatus = "active"; + } + else if (allFailed) + { + result.InitiativeStatus = "error"; + result.Error = "All channel dispatches failed"; + } + + result.Ok = anySuccess; + + _log.LogInformation( + "[Launch] Completed initiative {InitiativeId} | Channels={ChannelCount} Success={SuccessCount} Failed={FailCount}", + initiativeId, result.Channels.Count, + result.Channels.Count(c => c.Status != "error"), + result.Channels.Count(c => c.Status == "error")); + + return result; + } + + /// + /// Dispatch to a real provider service via ExecutionService. + /// Builds a GoogleApi-compatible request with proper payload structure. + /// + private async Task DispatchRealAsync( + long channelCampaignId, + string channelType, + ProviderConfig config, + string? campaignName, + string? objective, + decimal budget, + string? budgetPeriod, + string? businessCategory, + string clientId, + CancellationToken ct) + { + var result = new ChannelLaunchResult + { + ChannelCampaignId = channelCampaignId, + ChannelType = channelType, + }; + + // Convert budget: initiative stores dollars, GoogleApi expects daily micros + var dailyBudget = ConvertToDailyBudget(budget, budgetPeriod); + var budgetMicros = (long)(dailyBudget * 1_000_000m); + + // Map objective to campaign type + var campaignType = MapObjectiveToCampaignType(objective); + + // Build execution request with proper payload structure + // ExecutionService.BuildProviderRequest copies the "payload" field through + var providerName = MapChannelToProvider(channelType); + var execRequest = JsonSerializer.Serialize(new + { + provider = providerName, + service = "campaign", + action = "create", + operation = "CreateCampaign", + payload = new + { + name = campaignName ?? "Campaign", + type = campaignType, + budgetMicros = budgetMicros, + biddingStrategy = MapObjectiveToBiddingStrategy(objective), + } + }); + + _log.LogInformation( + "[Launch] Dispatching {Channel} to provider {Provider} | Budget=${Budget}/mo → {DailyBudget}/day → {BudgetMicros} micros | Type={CampaignType}", + channelType, providerName, budget, dailyBudget, budgetMicros, campaignType); + + // Call ExecutionService (handles routing, auth, logging) + var execDoc = JsonDocument.Parse(execRequest); + var respJson = await _execution.ExecuteAsync(execDoc.RootElement, ct); + + // Parse wrapped response: + // { ok, status, result: { ok, data: { campaignResourceName, externalId, ... } } } + using var respDoc = JsonDocument.Parse(respJson); + var respRoot = respDoc.RootElement; + + // Check wrapper ok + var wrapperOk = respRoot.TryGetProperty("ok", out var wrapOkEl) && wrapOkEl.GetBoolean(); + + // Navigate into result.data for the actual response + string? externalId = null; + bool providerOk = false; + + if (respRoot.TryGetProperty("result", out var resultEl)) + { + providerOk = resultEl.TryGetProperty("ok", out var provOkEl) && provOkEl.GetBoolean(); + + if (resultEl.TryGetProperty("data", out var dataEl)) + { + // Real API returns campaignResourceName + if (dataEl.TryGetProperty("campaignResourceName", out var crnEl)) + externalId = crnEl.GetString(); + // Emulated returns externalId + else if (dataEl.TryGetProperty("externalId", out var extEl)) + externalId = extEl.GetString(); + } + + // Also check for flat error at result level + if (!providerOk && resultEl.TryGetProperty("error", out var errEl)) + { + var errorMsg = errEl.ValueKind == JsonValueKind.Object + ? (errEl.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : errEl.GetRawText()) + : errEl.GetString(); + result.Status = "error"; + result.Message = $"Provider error: {errorMsg}"; + await SyncChannelCampaign(channelCampaignId, null, "error", errorMsg, ct); + return result; + } + } + + if (wrapperOk && providerOk) + { + var platformStatus = _statusNorm.Normalize(channelType, "submitted"); + result.Status = platformStatus; + result.ExternalCampaignId = externalId; + result.Message = $"Successfully dispatched to {config.DisplayName}"; + + _log.LogInformation( + "[Launch] {Channel} dispatched successfully | ExternalId={ExternalId} PlatformStatus={Status}", + channelType, externalId, platformStatus); + + // Sync back to DB + await SyncChannelCampaign(channelCampaignId, externalId, platformStatus, "submitted", ct); + } + else + { + var error = "Provider returned error"; + // Try to extract error message + if (respRoot.TryGetProperty("result", out var resEl) && + resEl.TryGetProperty("error", out var errObj)) + { + error = errObj.ValueKind == JsonValueKind.Object + ? (errObj.TryGetProperty("message", out var m) ? m.GetString() : errObj.GetRawText()) + : errObj.GetString(); + } + + result.Status = "error"; + result.Message = $"Provider error: {error}"; + + _log.LogWarning("[Launch] {Channel} dispatch failed | Error={Error}", channelType, error); + + await SyncChannelCampaign(channelCampaignId, null, "error", error, ct); + } + + return result; + } + + /// + /// Simulate dispatch for stub/unconfigured providers. + /// Marks the channel as "pending_review" since there's no real provider to call. + /// + private async Task DispatchStubAsync( + long channelCampaignId, + string channelType, + ProviderConfig config, + CancellationToken ct) + { + _log.LogInformation("[Launch] Stub dispatch for {Channel} (no real provider)", channelType); + + // Simulate a short delay for realism + await Task.Delay(100, ct); + + var result = new ChannelLaunchResult + { + ChannelCampaignId = channelCampaignId, + ChannelType = channelType, + Status = _statusNorm.Normalize(channelType, "pending_review"), + Message = $"{config.DisplayName} campaign queued for review (provider coming soon)", + IsStub = true, + }; + + // Sync to DB: chcStatus = normalized, chcProviderStatus = raw + await SyncChannelCampaign(channelCampaignId, null, result.Status, "stub_provider", ct); + + return result; + } + + /// Update channel campaign status in DB. + private async Task SyncChannelCampaign( + long channelCampaignId, + string? externalCampaignId, + string status, + string? providerStatus, + CancellationToken ct) + { + try + { + await _sql.ExecProcAsync( + SqlNames.Procs.ChannelCampaign, "sync", + JsonSerializer.Serialize(new + { + channelCampaignId, + externalCampaignId = externalCampaignId, + status = status, + providerStatus = providerStatus, + }), ct: ct); + } + catch (Exception ex) + { + _log.LogError(ex, "[Launch] Failed to sync channel campaign {Id}", channelCampaignId); + } + } + + /// Update initiative status in DB. + private async Task UpdateInitiativeStatus(long initiativeId, string status, CancellationToken ct) + { + try + { + await _sql.ExecProcAsync( + SqlNames.Procs.Initiative, "updateStatus", + JsonSerializer.Serialize(new { initiativeId, status }), ct: ct); + } + catch (Exception ex) + { + _log.LogError(ex, "[Launch] Failed to update initiative status {Id}", initiativeId); + } + } + + /// Map channel type to execution provider name. + private static string MapChannelToProvider(string channelType) + { + return channelType switch + { + "google_ads" => "google", + "meta" => "meta", + "tiktok" => "tiktok", + _ => channelType + }; + } + + /// + /// Check if a real provider URL is configured for this channel type. + /// Uses the same env var pattern as ExecutionService for routing. + /// + private bool IsProviderUrlConfigured(string channelType) + { + var envVarName = channelType switch + { + "google_ads" => "GOOGLE_PROVIDER_URL", + "meta" => "META_PROVIDER_URL", + "tiktok" => "TIKTOK_PROVIDER_URL", + _ => null + }; + + if (envVarName == null) return false; + + var url = _appConfig[envVarName]; + return !string.IsNullOrWhiteSpace(url); + } + + /// + /// Convert initiative budget (dollars per period) to daily budget. + /// Google Ads API operates on daily budgets. + /// + private static decimal ConvertToDailyBudget(decimal budget, string? budgetPeriod) + { + return (budgetPeriod?.ToLowerInvariant()) switch + { + "daily" => budget, + "weekly" => budget / 7m, + "monthly" => budget / 30.4m, // Google's standard month divisor + _ => budget / 30.4m // Default to monthly + }; + } + + /// + /// Map platform objective to Google Ads campaign type. + /// This determines the advertising channel (Search, Display, etc.) + /// + private static string MapObjectiveToCampaignType(string? objective) + { + return (objective?.ToLowerInvariant()) switch + { + "awareness" => "Display", // Brand awareness → Display network + "traffic" => "Search", // Website traffic → Search ads + "leads" => "Search", // Lead generation → Search ads + "conversions" => "Search", // Conversions → Search ads + "sales" => "PerformanceMax", // Sales → Performance Max + "engagement" => "Display", // Engagement → Display network + _ => "Search" // Default to Search + }; + } + + /// + /// Map platform objective to a bidding strategy. + /// + private static string MapObjectiveToBiddingStrategy(string? objective) + { + return (objective?.ToLowerInvariant()) switch + { + "awareness" => "MaximizeClicks", // Broad reach + "traffic" => "MaximizeClicks", // Drive traffic + "leads" => "MaximizeConversions", // Optimize for leads + "conversions" => "MaximizeConversions", // Optimize for conversions + "sales" => "MaximizeConversions", // Optimize for sales + _ => "MaximizeClicks" // Safe default + }; + } + + // ── JSON field helpers (try both clean and prefixed names) ── + + private static string? TryStr(JsonElement el, string key1, string key2) + { + if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.String) return p1.GetString(); + if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.String) return p2.GetString(); + return null; + } + + private static decimal TryDec(JsonElement el, string key1, string key2) + { + if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.Number) return p1.GetDecimal(); + if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.Number) return p2.GetDecimal(); + return 0m; + } + + private static long TryLong(JsonElement el, string key1, string key2) + { + if (el.TryGetProperty(key1, out var p1) && p1.ValueKind == JsonValueKind.Number) return p1.GetInt64(); + if (el.TryGetProperty(key2, out var p2) && p2.ValueKind == JsonValueKind.Number) return p2.GetInt64(); + return 0; + } + + private static IEnumerable EnumerateKeys(JsonElement el) + { + if (el.ValueKind == JsonValueKind.Object) + foreach (var prop in el.EnumerateObject()) + yield return prop.Name; + } +} + +// ──────────────────────────────────────────────── +// Result DTOs +// ──────────────────────────────────────────────── + +public sealed class LaunchResult +{ + public bool Ok { get; set; } + public long InitiativeId { get; set; } + public string? InitiativeStatus { get; set; } + public string? Error { get; set; } + public List Channels { get; set; } = new(); +} + +public sealed class ChannelLaunchResult +{ + public long ChannelCampaignId { get; set; } + public string ChannelType { get; set; } = ""; + public string Status { get; set; } = "pending"; + public string? Message { get; set; } + public string? ExternalCampaignId { get; set; } + public bool IsStub { get; set; } + public bool Skipped { get; set; } +} \ No newline at end of file diff --git a/Gateway/Services/IntelligenceApiClient.cs b/Gateway/Services/IntelligenceApiClient.cs new file mode 100644 index 0000000..8e74bce --- /dev/null +++ b/Gateway/Services/IntelligenceApiClient.cs @@ -0,0 +1,214 @@ +using Gateway.Models; +using System.Text.Json; + +namespace Gateway.Services; + +/// +/// HTTP client for IntelligenceApi — the spend distribution engine container. +/// +/// The Gateway injects clientCategory from ClientContext and provider config +/// before forwarding requests. The client portal never calls IntelligenceApi +/// directly; all routing goes through the Gateway. +/// +/// FALLBACK: If IntelligenceApi is unreachable, ForecastController falls back +/// to the local ForecastService (identical to the General engine output). +/// This means a container restart or deployment never breaks the wizard. +/// +public sealed class IntelligenceApiClient +{ + private readonly IHttpClientFactory _http; + private readonly IConfiguration _cfg; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions _jsonOpts = + new(JsonSerializerDefaults.Web); + + public IntelligenceApiClient( + IHttpClientFactory http, + IConfiguration cfg, + ILogger logger) + { + _http = http; + _cfg = cfg; + _logger = logger; + } + + /// + /// Forward raw census data for a ZCTA to the Intelligence container for + /// market analysis derivation (age chips, income tiers, insight strings). + /// Returns the raw JSON response string, or null if the container is + /// unreachable — caller falls back to returning raw census data. + /// + public async Task GetDemographicAnalysisAsync( + string zcta, + JsonElement censusData, + CancellationToken ct) + { + var baseUrl = _cfg["INTELLIGENCE_API_URL"] + ?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL"); + + if (string.IsNullOrWhiteSpace(baseUrl)) + { + _logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping demographics analysis"); + return null; + } + + var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"] + ?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY"); + + var payload = JsonSerializer.Serialize(new + { + zcta, + census = censusData + }, _jsonOpts); + + try + { + var client = _http.CreateClient(); + using var msg = new HttpRequestMessage( + HttpMethod.Post, + $"{baseUrl.TrimEnd('/')}/api/demographics/analyze"); + + if (!string.IsNullOrWhiteSpace(internalKey)) + msg.Headers.Add("X-Internal-Key", internalKey); + + msg.Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json"); + + _logger.LogInformation("[IntelligenceApiClient] Demographics analysis | ZCTA={Zcta}", zcta); + + using var resp = await client.SendAsync(msg, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("[IntelligenceApiClient] Demographics non-success {Status}", (int)resp.StatusCode); + return null; + } + + return body; + } + catch (TaskCanceledException) + { + _logger.LogWarning("[IntelligenceApiClient] Demographics analysis timed out | ZCTA={Zcta}", zcta); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "[IntelligenceApiClient] Demographics analysis failed | ZCTA={Zcta}", zcta); + return null; + } + } + + /// + /// Forward a channel forecast request to IntelligenceApi with + /// clientCategory injected. Returns null if the service is unreachable + /// or returns an error — caller should fall back to ForecastService. + /// + public async Task GetSpendDistributionAsync( + ChannelForecastRequest request, + string? clientCategory, + CancellationToken ct) + { + var baseUrl = _cfg["INTELLIGENCE_API_URL"] + ?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL"); + + if (string.IsNullOrWhiteSpace(baseUrl)) + { + _logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping"); + return null; + } + + var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"] + ?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY"); + + // Build the IntelligenceApi request — forward everything from the + // original wizard request, plus inject clientCategory and provider config + var intelligenceRequest = new + { + clientCategory = clientCategory ?? "General", + objective = request.Objective, + businessCategory = request.BusinessCategory, + keywords = request.Keywords, + geoTargeting = request.GeoTargeting, + audience = request.Audience, + monthlyBudget = request.MonthlyBudget, + channels = request.Channels, + + // Forward provider URLs so the engine can call providers directly + providerUrls = new Dictionary + { + ["google_ads"] = _cfg["GOOGLE_PROVIDER_URL"] + ?? Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL") ?? "" + }, + internalKeys = new Dictionary + { + ["google_ads"] = _cfg["GOOGLE_INTERNAL_KEY"] + ?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY") ?? "" + } + }; + + try + { + var client = _http.CreateClient(); + using var msg = new HttpRequestMessage( + HttpMethod.Post, + $"{baseUrl.TrimEnd('/')}/api/spend-distribution"); + + if (!string.IsNullOrWhiteSpace(internalKey)) + msg.Headers.Add("X-Internal-Key", internalKey); + + msg.Content = new StringContent( + JsonSerializer.Serialize(intelligenceRequest, _jsonOpts), + System.Text.Encoding.UTF8, "application/json"); + + _logger.LogInformation( + "[IntelligenceApiClient] Calling engine | Category={Category} Budget={Budget}", + clientCategory, request.MonthlyBudget); + + using var resp = await client.SendAsync(msg, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning( + "[IntelligenceApiClient] Non-success {Status}: {Body}", + (int)resp.StatusCode, body[..Math.Min(body.Length, 200)]); + return null; + } + + // Map IntelligenceApi response shape → Gateway ChannelForecastResponse + // The shapes are intentionally aligned so this is a straight deserialize. + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean()) + { + _logger.LogWarning("[IntelligenceApiClient] Engine returned ok=false"); + return null; + } + + // Re-serialize then deserialize into the Gateway model + // (avoids a hard dependency on IntelligenceApi model types in Gateway) + var result = JsonSerializer.Deserialize(body, _jsonOpts); + + _logger.LogInformation( + "[IntelligenceApiClient] OK | Engine={Engine} Channels={N}", + root.TryGetProperty("metadata", out var meta) + && meta.TryGetProperty("engine", out var eng) + ? eng.GetString() : "?", + result?.Channels?.Count ?? 0); + + return result; + } + catch (TaskCanceledException) + { + _logger.LogWarning("[IntelligenceApiClient] Request timed out"); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "[IntelligenceApiClient] Request failed"); + return null; + } + } +} diff --git a/Gateway/Services/MetricSyncService.cs b/Gateway/Services/MetricSyncService.cs new file mode 100644 index 0000000..9803463 --- /dev/null +++ b/Gateway/Services/MetricSyncService.cs @@ -0,0 +1,339 @@ +using Gateway.Data; +using Gateway.Security; +using System.Text.Json; + +namespace Gateway.Services; + +/// +/// Orchestrates pulling campaign performance metrics from providers +/// and writing them into the database via spPerformanceMetric. +/// +/// Flow: +/// 1. Get active channel campaigns (from spChannelCampaign listByClient) +/// 2. For each channel campaign with an external campaign ID: +/// - Call the appropriate provider's reporting endpoint +/// - Transform provider response into standard metric format +/// - Upsert into tbPerformanceMetric via spPerformanceMetric.upsertBatch +/// 3. After metrics are synced, trigger recommendation evaluation +/// +/// Called by: +/// - Admin endpoint (manual trigger) +/// - Background polling (future: Azure Functions timer trigger) +/// +public sealed class MetricSyncService +{ + private readonly SqlService _sql; + private readonly IHttpClientFactory _http; + private readonly IConfiguration _cfg; + private readonly ILogger _logger; + + public MetricSyncService( + SqlService sql, + IHttpClientFactory http, + IConfiguration cfg, + ILogger logger) + { + _sql = sql; + _http = http; + _cfg = cfg; + _logger = logger; + } + + /// + /// Sync metrics for a specific client's active campaigns. + /// + public async Task SyncClientMetricsAsync( + string clientId, string? startDate, string? endDate, CancellationToken ct) + { + var result = new SyncResult { ClientId = clientId }; + + try + { + // 1. Get active channel campaigns for this client + var listResp = await _sql.ExecProcAsync( + SqlNames.Procs.ChannelCampaign, "listByClient", + JsonSerializer.Serialize(new { clientId }), ct: ct); + + if (string.IsNullOrWhiteSpace(listResp)) + { + result.Error = "Failed to retrieve channel campaigns"; + return result; + } + + using var doc = JsonDocument.Parse(listResp); + var root = doc.RootElement; + + if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean()) + { + result.Error = "Channel campaign query returned error"; + return result; + } + + // Extract campaigns array + JsonElement campaigns; + if (root.TryGetProperty("channelCampaigns", out campaigns) || + root.TryGetProperty("channels", out campaigns)) + { + // ok + } + else + { + result.Error = "No channel campaigns found in response"; + return result; + } + + if (campaigns.ValueKind != JsonValueKind.Array) + { + result.Error = "Channel campaigns is not an array"; + return result; + } + + // 2. Sync each active channel campaign + foreach (var cc in campaigns.EnumerateArray()) + { + var chcId = cc.TryGetProperty("channelCampaignId", out var chcIdProp) ? chcIdProp.GetInt64() : + cc.TryGetProperty("chcId", out var chcProp) ? chcProp.GetInt64() : 0; + var channelType = cc.TryGetProperty("channelType", out var ctProp) ? ctProp.GetString() : + cc.TryGetProperty("chcChannelType", out var chcCtProp) ? chcCtProp.GetString() : null; + var status = cc.TryGetProperty("status", out var stProp) ? stProp.GetString() : + cc.TryGetProperty("chcStatus", out var chcStProp) ? chcStProp.GetString() : null; + + if (chcId == 0 || string.IsNullOrWhiteSpace(channelType)) continue; + if (status != "active") continue; + + result.CampaignsProcessed++; + + try + { + var provider = MapChannelToProvider(channelType); + var providerUrl = GetProviderUrl(provider); + var providerKey = GetProviderKey(provider); + + if (string.IsNullOrWhiteSpace(providerUrl)) + { + _logger.LogWarning("[MetricSync] No URL for provider {Provider}, skipping chcId={ChcId}", + provider, chcId); + result.Skipped++; + continue; + } + + // Get external campaign ID + // providerPayload from the channel campaign contains the external mapping + var externalCampaignId = cc.TryGetProperty("externalCampaignId", out var extIdProp) + ? extIdProp.GetString() : null; + + if (string.IsNullOrWhiteSpace(externalCampaignId)) + { + // Try to extract from providerPayload JSON + if (cc.TryGetProperty("providerPayload", out var ppProp) && + ppProp.ValueKind == JsonValueKind.String) + { + try + { + using var ppDoc = JsonDocument.Parse(ppProp.GetString()!); + externalCampaignId = ppDoc.RootElement.TryGetProperty("externalId", out var eidProp) + ? eidProp.GetString() : null; + } + catch { /* ignore parse errors */ } + } + } + + if (string.IsNullOrWhiteSpace(externalCampaignId)) + { + _logger.LogDebug("[MetricSync] No externalCampaignId for chcId={ChcId}, skipping", chcId); + result.Skipped++; + continue; + } + + // Call provider reporting endpoint + var reportPayload = new + { + operation = "GetCampaignReport", + tenantId = GetTenantId(cc), + requestId = Guid.NewGuid().ToString("N"), + payload = new + { + campaignId = externalCampaignId, + startDate = startDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd"), + endDate = endDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd") + } + }; + + var httpClient = _http.CreateClient(); + using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute"); + msg.Headers.Add("X-Internal-Key", providerKey); + msg.Content = new StringContent( + JsonSerializer.Serialize(reportPayload), + System.Text.Encoding.UTF8, "application/json"); + + using var resp = await httpClient.SendAsync(msg, ct); + var respBody = await resp.Content.ReadAsStringAsync(ct); + + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("[MetricSync] Provider returned {Status} for chcId={ChcId}", + resp.StatusCode, chcId); + result.Errors++; + continue; + } + + // Parse provider response and extract daily rows + using var respDoc = JsonDocument.Parse(respBody); + var respRoot = respDoc.RootElement; + + JsonElement data; + if (respRoot.TryGetProperty("data", out data) || + respRoot.TryGetProperty("Data", out data)) + { + // ok + } + else + { + data = respRoot; + } + + if (!data.TryGetProperty("rows", out var rowsEl) || + rowsEl.ValueKind != JsonValueKind.Array) + { + _logger.LogWarning("[MetricSync] No rows in provider response for chcId={ChcId}", chcId); + result.Errors++; + continue; + } + + // Transform rows into upsertBatch format + var metrics = new List(); + foreach (var row in rowsEl.EnumerateArray()) + { + var metricDate = row.TryGetProperty("date", out var dProp) ? dProp.GetString() : null; + if (string.IsNullOrWhiteSpace(metricDate)) continue; + + metrics.Add(new + { + channelCampaignId = chcId, + metricDate, + impressions = GetLong(row, "impressions"), + clicks = GetLong(row, "clicks"), + spend = GetDecimal(row, "spend") ?? (GetLong(row, "costMicros") / 1_000_000.0m), + conversions = GetDecimal(row, "conversions") ?? 0, + conversionValue = GetDecimal(row, "conversionValue") ?? 0, + sourceAttribution = "provider" + }); + } + + if (metrics.Count == 0) continue; + + // Upsert into database + var upsertResp = await _sql.ExecProcAsync( + SqlNames.Procs.PerformanceMetric, "upsertBatch", + JsonSerializer.Serialize(new { metrics }), ct: ct); + + _logger.LogInformation( + "[MetricSync] Synced {Count} rows for chcId={ChcId} channel={Channel}", + metrics.Count, chcId, channelType); + + result.MetricsWritten += metrics.Count; + } + catch (Exception ex) + { + _logger.LogError(ex, "[MetricSync] Error syncing chcId={ChcId}", chcId); + result.Errors++; + } + } + + // 3. Trigger recommendation evaluation for this client + if (result.MetricsWritten > 0) + { + try + { + var evalResp = await _sql.ExecProcAsync( + SqlNames.Procs.Recommendation, "evaluate", + JsonSerializer.Serialize(new { clientId }), ct: ct); + + if (!string.IsNullOrWhiteSpace(evalResp)) + { + using var evalDoc = JsonDocument.Parse(evalResp); + if (evalDoc.RootElement.TryGetProperty("generated", out var genProp)) + result.RecommendationsGenerated = genProp.GetInt32(); + } + + _logger.LogInformation( + "[MetricSync] Evaluation complete for client {ClientId} | Recommendations={Recommendations}", + clientId, result.RecommendationsGenerated); + } + catch (Exception ex) + { + _logger.LogError(ex, "[MetricSync] Evaluation failed for client {ClientId}", clientId); + } + } + + result.Success = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "[MetricSync] Sync failed for client {ClientId}", clientId); + result.Error = ex.Message; + } + + return result; + } + + // ════════════════════════════════════════════════ + // Helpers + // ════════════════════════════════════════════════ + + private static string MapChannelToProvider(string channelType) => + channelType.ToLowerInvariant() switch + { + "google_ads" or "google" => "google", + "meta" or "facebook" => "meta", + "tiktok" => "tiktok", + _ => channelType + }; + + private string GetProviderUrl(string provider) => + provider.ToLowerInvariant() switch + { + "google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "", + "meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "", + "tiktok" => _cfg["TIKTOK_PROVIDER_URL"]?.TrimEnd('/') ?? "", + _ => "" + }; + + private string GetProviderKey(string provider) => + provider.ToLowerInvariant() switch + { + "google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "", + "meta" => _cfg["META_INTERNAL_KEY"] ?? "", + "tiktok" => _cfg["TIKTOK_INTERNAL_KEY"] ?? "", + _ => "" + }; + + private static string? GetTenantId(JsonElement cc) + { + if (cc.TryGetProperty("externalAccountId", out var eaProp)) return eaProp.GetString(); + if (cc.TryGetProperty("chcExternalAccountId", out var chcEaProp)) return chcEaProp.GetString(); + return null; + } + + private static long GetLong(JsonElement el, string prop) => + el.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; + + private static decimal? GetDecimal(JsonElement el, string prop) + { + if (!el.TryGetProperty(prop, out var p)) return null; + return p.ValueKind == JsonValueKind.Number ? p.GetDecimal() : null; + } +} + +/// Result of a metric sync operation. +public sealed class SyncResult +{ + public string? ClientId { get; set; } + public bool Success { get; set; } + public int CampaignsProcessed { get; set; } + public int MetricsWritten { get; set; } + public int RecommendationsGenerated { get; set; } + public int Skipped { get; set; } + public int Errors { get; set; } + public string? Error { get; set; } +} diff --git a/Gateway/Services/ProviderStatusNormalizer.cs b/Gateway/Services/ProviderStatusNormalizer.cs new file mode 100644 index 0000000..14de99c --- /dev/null +++ b/Gateway/Services/ProviderStatusNormalizer.cs @@ -0,0 +1,160 @@ +using Gateway.Models; +using Microsoft.Extensions.Options; + +namespace Gateway.Services; + +/// +/// Normalizes provider-specific campaign statuses into platform-standard statuses. +/// +/// Each advertising channel (Google Ads, Meta, TikTok, etc.) reports campaign state +/// using its own vocabulary. This service translates those into the platform's +/// unified status set: draft, staged, pending, active, paused, completed, cancelled, error. +/// +/// Mapping priority: +/// 1. Channel-specific mapping from config (e.g. google_ads → ENABLED → active) +/// 2. Common/internal mappings (e.g. submitted → active, pending_review → pending) +/// 3. Pass-through if the raw status is already a valid platform status +/// 4. "error" fallback with a warning log for truly unknown statuses +/// +public sealed class ProviderStatusNormalizer +{ + private readonly MultiChannelConfig _config; + private readonly ILogger _log; + + /// The canonical set of platform-level statuses. + private static readonly HashSet PlatformStatuses = new(StringComparer.OrdinalIgnoreCase) + { + "draft", "staged", "pending", "active", "paused", "completed", "cancelled", "error" + }; + + /// + /// Internal/transitional statuses used during launch orchestration. + /// These are not provider-specific but arise from the platform's own workflow. + /// + private static readonly Dictionary CommonMappings = new(StringComparer.OrdinalIgnoreCase) + { + // Launch service assigns these during dispatch + ["submitted"] = "active", + ["pending_review"] = "pending", + ["stub_provider"] = "pending", + + // Webhook / callback transitional states + ["approved"] = "active", + ["rejected"] = "error", + ["suspended"] = "paused", + ["budget_depleted"] = "paused", + ["expired"] = "completed", + ["archived"] = "completed", + ["deleted"] = "cancelled", + ["in_process"] = "pending", + ["in_review"] = "pending", + ["learning"] = "active", // Meta "learning phase" + ["limited"] = "active", // Google "limited by budget" etc. + }; + + public ProviderStatusNormalizer( + IOptions config, + ILogger log) + { + _config = config.Value; + _log = log; + } + + /// + /// Normalize a raw provider status into a platform status. + /// + /// Channel identifier (e.g. "google_ads", "meta", "tiktok"). + /// The status string as returned by the provider. + /// A valid platform status string. + public string Normalize(string? channelType, string? rawProviderStatus) + { + if (string.IsNullOrWhiteSpace(rawProviderStatus)) + return "error"; + + var raw = rawProviderStatus.Trim(); + + // 1. Try channel-specific mapping from config + if (!string.IsNullOrWhiteSpace(channelType)) + { + var provider = _config.GetChannel(channelType); + if (provider?.StatusMappings != null && + provider.StatusMappings.TryGetValue(raw, out var mapped)) + { + _log.LogDebug("[StatusNorm] {Channel}/{RawStatus} → {PlatformStatus} (config)", + channelType, raw, mapped); + return mapped; + } + } + + // 2. Try common/internal mappings + if (CommonMappings.TryGetValue(raw, out var common)) + { + _log.LogDebug("[StatusNorm] {RawStatus} → {PlatformStatus} (common)", + raw, common); + return common; + } + + // 3. If the raw value is already a valid platform status, pass through + if (PlatformStatuses.Contains(raw)) + { + _log.LogDebug("[StatusNorm] {RawStatus} → pass-through (already platform status)", raw); + return raw.ToLowerInvariant(); + } + + // 4. Unknown — log warning and return "error" + _log.LogWarning( + "[StatusNorm] Unknown provider status: channel={Channel}, raw={RawStatus}. Defaulting to 'error'. " + + "Add a mapping in MultiChannel.Channels[].StatusMappings or CommonMappings.", + channelType ?? "(none)", raw); + + return "error"; + } + + /// + /// Resolve the platform status for a sync operation. + /// If an explicit platform status is provided, validate and use it. + /// Otherwise, normalize the provider status. + /// + /// Channel identifier. + /// Explicitly provided platform status (optional). + /// Raw provider status (optional). + /// A valid platform status string. + public string Resolve(string? channelType, string? explicitStatus, string? rawProviderStatus) + { + // If an explicit platform status was given, validate it + if (!string.IsNullOrWhiteSpace(explicitStatus)) + { + if (PlatformStatuses.Contains(explicitStatus)) + return explicitStatus.ToLowerInvariant(); + + _log.LogWarning("[StatusNorm] Invalid explicit status '{Status}', normalizing as provider status instead.", + explicitStatus); + // Fall through to normalization + } + + return Normalize(channelType, rawProviderStatus); + } + + /// + /// Get all configured mappings for a channel (for diagnostics / admin display). + /// + public Dictionary GetMappings(string channelType) + { + var result = new Dictionary(CommonMappings, StringComparer.OrdinalIgnoreCase); + + var provider = _config.GetChannel(channelType); + if (provider?.StatusMappings != null) + { + foreach (var kv in provider.StatusMappings) + result[kv.Key] = kv.Value; // Channel-specific overrides common + } + + return result; + } + + /// + /// Check whether a string is a valid platform status. + /// + public static bool IsValidPlatformStatus(string? status) => + !string.IsNullOrWhiteSpace(status) && PlatformStatuses.Contains(status); +} diff --git a/Gateway/appsettings.json b/Gateway/appsettings.json index c447d54..75c3327 100644 --- a/Gateway/appsettings.json +++ b/Gateway/appsettings.json @@ -9,10 +9,38 @@ "Auth": { "AllowDevBypass": false, + + "Microsoft": { + "TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2", + "ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e", + "StaffTenantId": "0be4c23a-6941-4bdb-b397-a4faf88de4b3", + "StaffClientId": "b0f29246-91e7-4615-96db-5de9b6f8da2e" + }, + "EntraId": { "Instance": "https://login.microsoftonline.com/", "TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2", "ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e" } + }, + + "BlobStorage": { + "ConnectionString": "", + "ContainerName": "creative-images", + "BaseUrl": "https://usimadpcreatives.blob.core.windows.net" + }, + + "MultiChannel": { + "Allocation": { + "MinMultiChannelMonthlyBudget": 500.00, + "MaxChannelsPerInitiative": 5, + "DefaultAllocationStrategy": "template", + "PerformanceEvalIntervalDays": 7, + "PerformanceLookbackDays": 14, + "PerformanceLearningPeriodDays": 14, + "MaxAllocationShiftPct": 15.00, + "MinChannelAllocationPct": 10.00, + "MaxChannelAllocationPct": 80.00 + } } } diff --git a/GoogleApi/GOOGLE_ADS_SETUP.md b/GoogleApi/GOOGLE_ADS_SETUP.md deleted file mode 100644 index e205b05..0000000 --- a/GoogleApi/GOOGLE_ADS_SETUP.md +++ /dev/null @@ -1,259 +0,0 @@ -# Google Ads API Configuration Guide - -## Overview - -This document describes how to configure the GoogleApi service to connect to the real Google Ads API. The service supports both **emulated mode** (for testing without Google credentials) and **real API mode**. - -## Configuration Levels - -| Level | Storage | Examples | -|-------|---------|----------| -| **Platform secrets** | Azure Key Vault | Developer token, OAuth client secret | -| **Platform config** | App Settings / appsettings.json | API version, timeouts | -| **Per-account credentials** | Database (tbGoogleCredential) | Refresh tokens per linked account | - -## Quick Start (Test Account) - -1. Create a Google Ads test manager account -2. Get a developer token (works immediately for test accounts) -3. Set up OAuth credentials in Google Cloud Console -4. Configure the environment variables below - -## Environment Variables for Azure Container Apps -> **Note:** This service runs **server-to-server**. There is **no interactive OAuth UI** at runtime. -> Generate the refresh token once (out-of-band) and store it securely (Key Vault / secrets). - - -### GoogleApi Service - -```bash -# ========================================== -# Core Settings -# ========================================== - -# Enable real Google Ads API calls (default: false) -GoogleAds__EnableRealApi=true - -# API version (default: v22) -GoogleAds__ApiVersion=v22 - -# ========================================== -# Authentication - Developer Token -# Required for all API calls -# ========================================== - -# Your developer token from Google Ads API Center -# Format: 22-character alphanumeric string -# Get from: https://ads.google.com/aw/apicenter -GoogleAds__DeveloperToken=YOUR_DEVELOPER_TOKEN_HERE - -# ========================================== -# Authentication - OAuth 2.0 -# Required for authenticating API requests -# ========================================== - -# OAuth Client ID from Google Cloud Console -GoogleAds__OAuth__ClientId=YOUR_CLIENT_ID.apps.googleusercontent.com - -# OAuth Client Secret from Google Cloud Console -# SENSITIVE - Use Key Vault reference in production -GoogleAds__OAuth__ClientSecret=YOUR_CLIENT_SECRET - -# Refresh token for platform-level access -# Generated via OAuth flow or gcloud CLI -# SENSITIVE - Use Key Vault reference in production -GoogleAds__OAuth__RefreshToken=YOUR_REFRESH_TOKEN - -# ========================================== -# Manager Account (Optional) -# Required if accessing client accounts under a manager -# ========================================== - -# Default login customer ID (manager account) -# Format: 1234567890 (no dashes) -GoogleAds__DefaultLoginCustomerId=1234567890 - -# ========================================== -# Internal Authentication -# For Gateway -> GoogleApi communication -# ========================================== - -# Shared secret for internal API authentication -# SENSITIVE - Use Key Vault reference -GOOGLE_INTERNAL_KEY=your-secure-internal-key - -# ========================================== -# Optional Settings -# ========================================== - -# HTTP timeout in seconds (default: 60) -GoogleAds__TimeoutSeconds=60 - -# Max retry attempts (default: 3) -GoogleAds__MaxRetries=3 -``` - -### Azure Key Vault References - -For sensitive values, use Key Vault references in Azure Container Apps: - -```bash -# Instead of plain values: -GoogleAds__DeveloperToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsDeveloperToken/) -GoogleAds__OAuth__ClientSecret=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsClientSecret/) -GoogleAds__OAuth__RefreshToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsRefreshToken/) -GOOGLE_INTERNAL_KEY=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleInternalKey/) -``` - -## Step-by-Step Setup - -### 1. Create Google Ads Manager Account - -1. Go to https://ads.google.com/aw/apicenter -2. Sign in with a Google account NOT linked to production ads -3. Create a new manager account -4. For test accounts, click "Create a test manager account" link - -### 2. Get Developer Token - -1. In your manager account, go to **Tools & Settings > API Center** -2. Your developer token will be displayed -3. For test accounts: Token works immediately -4. For production: Apply for Basic Access (takes a few days) - -### 3. Create Google Cloud Project - -1. Go to https://console.cloud.google.com -2. Create a new project (or use existing) -3. Enable the **Google Ads API**: - - Go to APIs & Services > Library - - Search "Google Ads API" - - Click Enable - -### 4. Create OAuth Credentials - -1. Go to APIs & Services > Credentials -2. Click **Create Credentials > OAuth client ID** -3. Application type: **Desktop app** (for initial testing) -4. Download the JSON file -5. Note the Client ID and Client Secret - -### 5. Generate Refresh Token - -Option A: Using gcloud CLI -```bash -# Install gcloud CLI if not installed -gcloud auth login --cred-file=path/to/client_secret.json - -gcloud auth print-access-token \ - --scopes='https://www.googleapis.com/auth/adwords' -``` - -Option B: Using OAuth Playground -1. Go to https://developers.google.com/oauthplayground/ -2. Click gear icon > Use your own credentials -3. Enter your Client ID and Secret -4. Select Google Ads API scope: `https://www.googleapis.com/auth/adwords` -5. Click Authorize APIs, sign in -6. Click "Exchange authorization code for tokens" -7. Copy the Refresh Token - -### 6. Create Test Client Account - -1. In your test manager account -2. Click Accounts > + > Create new account -3. This creates a test client account under your manager -4. Note the Customer ID (format: XXX-XXX-XXXX) - -### 7. Configure Azure Container App - -In Azure Portal > Container Apps > Your GoogleApi App > Settings > Environment Variables: - -Add each variable from the list above, using Key Vault references for sensitive values. - -## Testing the Configuration - -### Check Health Endpoint - -```bash -curl https://your-googleapi-url/health -``` - -Expected response: -```json -{ - "service": "GoogleApi", - "status": "healthy", - "config": { - "realApiEnabled": true, - "apiVersion": "v18", - "developerTokenSet": true, - "oauthConfigured": true, - "defaultLoginCustomerId": "1234567890" - } -} -``` - -### Test API Call (via Gateway) - -```bash -curl -X POST https://your-gateway-url/api/execution/request \ - -H "Content-Type: application/json" \ - -H "X-Dev-ClientId: test-client" \ - -H "X-Dev-TenantId: 1234567890" \ - -d '{ - "operation": "ListAccessibleCustomers", - "payload": {} - }' -``` - -## Credential Flow Diagram - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Gateway │────▶│ GoogleApi │────▶│ Google Ads │ -└─────────────┘ └─────────────┘ │ API │ - │ └─────────────┘ - │ - ┌──────┴──────┐ - │ │ - ┌─────▼─────┐ ┌────▼────┐ - │ Config │ │ Database │ - │(env vars) │ │(per-acct)│ - └───────────┘ └──────────┘ - -Config provides: Database provides: -- Developer Token - Per-account refresh tokens -- OAuth Client ID/Secret - Account-specific credentials -- Default refresh token - Linked customer IDs -``` - -## Troubleshooting - -### "UNAUTHENTICATED" Error -- Check developer token is correct -- Verify OAuth credentials -- Ensure refresh token hasn't expired - -### "PERMISSION_DENIED" Error -- Developer token may not be approved for production -- Verify account access permissions -- Check login-customer-id is correct - -### "INVALID_CUSTOMER_ID" Error -- Customer ID format should be 10 digits, no dashes -- Verify account exists and is accessible - -### Token Exchange Fails -- Client ID/Secret mismatch -- Refresh token was revoked -- OAuth consent was withdrawn - -## Security Best Practices - -1. **Never commit secrets** to source control -2. **Use Azure Key Vault** for all sensitive values -3. **Rotate refresh tokens** periodically -4. **Audit API access** via tbAdpApiLog -5. **Limit developer token access** - one token per application -6. **Use test accounts** for development and testing diff --git a/GoogleApi/GoogleApi.csproj b/GoogleApi/GoogleApi.csproj index b19c8a3..fbab445 100644 --- a/GoogleApi/GoogleApi.csproj +++ b/GoogleApi/GoogleApi.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/GoogleApi/Models/AudienceModels.cs b/GoogleApi/Models/AudienceModels.cs new file mode 100644 index 0000000..ae0b4e3 --- /dev/null +++ b/GoogleApi/Models/AudienceModels.cs @@ -0,0 +1,58 @@ +namespace GoogleApi.Models; + +/// +/// Represents an audience segment from Google Ads (affinity, in-market, life events, etc.) +/// +public class AudienceSegment +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; // AFFINITY, IN_MARKET, LIFE_EVENT, DETAILED_DEMOGRAPHIC + public string? ParentName { get; set; } + public int? ParentId { get; set; } +} + +/// +/// Response containing all available audience segments +/// +public class AudienceSegmentsResponse +{ + public List Affinity { get; set; } = new(); + public List InMarket { get; set; } = new(); + public List LifeEvents { get; set; } = new(); + public List DetailedDemographics { get; set; } = new(); + public int TotalCount => Affinity.Count + InMarket.Count + LifeEvents.Count + DetailedDemographics.Count; + public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow; +} + +/// +/// Geo target constant for location targeting +/// +public class GeoTarget +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string CanonicalName { get; set; } = string.Empty; + public string TargetType { get; set; } = string.Empty; // City, State, Country, etc. + public string? CountryCode { get; set; } + public string? ParentGeoTarget { get; set; } +} + +/// +/// Response for geo target search +/// +public class GeoTargetSearchResponse +{ + public List Results { get; set; } = new(); + public string Query { get; set; } = string.Empty; +} + +/// +/// Payload for searching geo targets +/// +public class GeoTargetSearchPayload +{ + public string Query { get; set; } = string.Empty; + public string? CountryCode { get; set; } + public int MaxResults { get; set; } = 20; +} diff --git a/GoogleApi/Models/ForecastModels.cs b/GoogleApi/Models/ForecastModels.cs new file mode 100644 index 0000000..464f388 --- /dev/null +++ b/GoogleApi/Models/ForecastModels.cs @@ -0,0 +1,59 @@ +namespace GoogleApi.Models; + +#region Forecast Payloads + +/// +/// Payload for KeywordForecast operation. +/// Gateway sends targeting + budget; we translate to GenerateKeywordForecastMetrics. +/// +public sealed class KeywordForecastPayload +{ + /// Keywords to forecast (from wizard Step 1 URL analysis) + public List Keywords { get; set; } = new(); + + /// Geo target IDs (Google Ads geo constants) + public List GeoTargetIds { get; set; } = new(); + + /// Monthly budget in whole currency units allocated to this channel + public decimal MonthlyBudget { get; set; } + + /// Currency code + public string CurrencyCode { get; set; } = "USD"; + + /// Forecast period in days (default 30) + public int ForecastDays { get; set; } = 30; + + /// Campaign type for bid simulation + public CampaignType CampaignType { get; set; } = CampaignType.Search; +} + +/// +/// Response from keyword forecast — monthly estimated metrics. +/// +public sealed class KeywordForecastResponse +{ + public string Provider { get; set; } = "google"; + public ForecastEstimates Monthly { get; set; } = new(); + public ForecastMetrics Metrics { get; set; } = new(); + public string Confidence { get; set; } = "none"; + public string DataSource { get; set; } = "emulated"; +} + +public sealed class ForecastEstimates +{ + public double Impressions { get; set; } + public double Clicks { get; set; } + public decimal Cost { get; set; } + public double Conversions { get; set; } + public double? Reach { get; set; } +} + +public sealed class ForecastMetrics +{ + public decimal AvgCpc { get; set; } + public decimal AvgCpm { get; set; } + public double Ctr { get; set; } + public decimal? EstimatedCpa { get; set; } +} + +#endregion diff --git a/GoogleApi/Models/OperationPayloads.cs b/GoogleApi/Models/OperationPayloads.cs index d1b230b..29ced0a 100644 --- a/GoogleApi/Models/OperationPayloads.cs +++ b/GoogleApi/Models/OperationPayloads.cs @@ -36,6 +36,25 @@ public sealed class ListCampaignsPayload #endregion +#region Account Payloads + +public sealed class CreateCustomerClientPayload +{ + /// Display name for the new sub-account (used for billing reconciliation) + public string AccountName { get; set; } = string.Empty; + + /// Currency code (e.g. "USD") + public string CurrencyCode { get; set; } = "USD"; + + /// Time zone (e.g. "America/Los_Angeles") + public string TimeZone { get; set; } = "America/Los_Angeles"; + + /// Optional descriptive name visible in MCC (defaults to AccountName) + public string? DescriptiveName { get; set; } +} + +#endregion + #region Reporting Payloads public sealed class CampaignStatsPayload diff --git a/GoogleApi/Program.cs b/GoogleApi/Program.cs index 66edc9a..68914eb 100644 --- a/GoogleApi/Program.cs +++ b/GoogleApi/Program.cs @@ -47,6 +47,9 @@ builder.Services.AddSwaggerGen(c => // Core services builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Auth filter for internal calls from Gateway diff --git a/GoogleApi/Services/AudienceService.cs b/GoogleApi/Services/AudienceService.cs new file mode 100644 index 0000000..5500242 --- /dev/null +++ b/GoogleApi/Services/AudienceService.cs @@ -0,0 +1,318 @@ +using Google.Ads.GoogleAds.Lib; +using Google.Ads.GoogleAds.V22.Services; +using Google.Ads.GoogleAds.V22.Resources; +using Google.Ads.GoogleAds.V22.Enums; +using GoogleApi.Configuration; +using GoogleApi.Models; +using Microsoft.Extensions.Options; + +// Alias to avoid namespace conflicts +using GAdsServices = global::Google.Ads.GoogleAds.Services; + +namespace GoogleApi.Services; + +/// +/// Service for querying Google Ads audience segments and geo targets. +/// +public sealed class AudienceService +{ + private readonly GoogleAdsConfig _config; + private readonly GoogleAdsClientFactory _clientFactory; + private readonly ILogger _logger; + + public AudienceService( + IOptions config, + GoogleAdsClientFactory clientFactory, + ILogger logger) + { + _config = config.Value; + _clientFactory = clientFactory; + _logger = logger; + } + + /// + /// Get all available audience segments (affinity, in-market, life events, detailed demographics) + /// + public async Task GetAudienceSegmentsAsync( + GoogleAdsContext context, + string requestId, + CancellationToken ct) + { + _logger.LogInformation("[Audience] Fetching audience segments | RequestId={RequestId}", requestId); + + if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId)) + { + return GetEmulatedAudienceSegments(requestId); + } + + try + { + var client = _clientFactory.CreateClient(context); + var googleAdsService = client.GetService(GAdsServices.V22.GoogleAdsService); + var customerId = context.CustomerId; + + var response = new AudienceSegmentsResponse(); + + // Query user interests (Affinity + In-Market) + var userInterestQuery = @" + SELECT + user_interest.user_interest_id, + user_interest.name, + user_interest.taxonomy_type, + user_interest.availabilities + FROM user_interest + WHERE user_interest.taxonomy_type IN ('AFFINITY', 'IN_MARKET')"; + + var userInterestResults = googleAdsService.Search(customerId, userInterestQuery); + + foreach (var row in userInterestResults) + { + var ui = row.UserInterest; + var segment = new AudienceSegment + { + Id = ui.UserInterestId, + Name = ui.Name, + Type = ui.TaxonomyType.ToString() + }; + + if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.Affinity) + response.Affinity.Add(segment); + else if (ui.TaxonomyType == UserInterestTaxonomyTypeEnum.Types.UserInterestTaxonomyType.InMarket) + response.InMarket.Add(segment); + } + + // Query life events + var lifeEventQuery = @" + SELECT + life_event.id, + life_event.name + FROM life_event"; + + var lifeEventResults = googleAdsService.Search(customerId, lifeEventQuery); + + foreach (var row in lifeEventResults) + { + var le = row.LifeEvent; + response.LifeEvents.Add(new AudienceSegment + { + Id = le.Id, + Name = le.Name, + Type = "LIFE_EVENT" + }); + } + + // Query detailed demographics + var detailedDemoQuery = @" + SELECT + detailed_demographic.id, + detailed_demographic.name + FROM detailed_demographic"; + + var detailedDemoResults = googleAdsService.Search(customerId, detailedDemoQuery); + + foreach (var row in detailedDemoResults) + { + var dd = row.DetailedDemographic; + response.DetailedDemographics.Add(new AudienceSegment + { + Id = dd.Id, + Name = dd.Name, + Type = "DETAILED_DEMOGRAPHIC" + }); + } + + _logger.LogInformation( + "[Audience] Retrieved {Total} segments (Affinity={Affinity}, InMarket={InMarket}, LifeEvents={Life}, Demographics={Demo}) | RequestId={RequestId}", + response.TotalCount, response.Affinity.Count, response.InMarket.Count, + response.LifeEvents.Count, response.DetailedDemographics.Count, requestId); + + return ProviderResponse.Success(requestId, response); + } + catch (Exception ex) + { + _logger.LogError(ex, "[Audience] Failed to fetch segments | RequestId={RequestId}", requestId); + return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message); + } + } + + /// + /// Search for geo target constants by name + /// + public async Task SearchGeoTargetsAsync( + GeoTargetSearchPayload payload, + GoogleAdsContext context, + string requestId, + CancellationToken ct) + { + _logger.LogInformation("[Audience] Searching geo targets: {Query} | RequestId={RequestId}", + payload.Query, requestId); + + if (!_clientFactory.IsRealApiEnabled || string.IsNullOrWhiteSpace(context.CustomerId)) + { + return GetEmulatedGeoTargets(payload.Query, requestId); + } + + try + { + var client = _clientFactory.CreateClient(context); + var geoService = client.GetService(GAdsServices.V22.GeoTargetConstantService); + + var request = new SuggestGeoTargetConstantsRequest + { + Locale = "en", + CountryCode = payload.CountryCode ?? "US", + LocationNames = new SuggestGeoTargetConstantsRequest.Types.LocationNames() + }; + request.LocationNames.Names.Add(payload.Query); + + var response = await geoService.SuggestGeoTargetConstantsAsync(request); + + var results = response.GeoTargetConstantSuggestions + .Take(payload.MaxResults) + .Select(s => new GeoTarget + { + Id = s.GeoTargetConstant.Id, + Name = s.GeoTargetConstant.Name, + CanonicalName = s.GeoTargetConstant.CanonicalName, + TargetType = s.GeoTargetConstant.TargetType, + CountryCode = s.GeoTargetConstant.CountryCode, + ParentGeoTarget = s.GeoTargetConstant.ParentGeoTarget + }) + .ToList(); + + _logger.LogInformation("[Audience] Found {Count} geo targets for '{Query}' | RequestId={RequestId}", + results.Count, payload.Query, requestId); + + return ProviderResponse.Success(requestId, new GeoTargetSearchResponse + { + Query = payload.Query, + Results = results + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[Audience] Failed to search geo targets | RequestId={RequestId}", requestId); + return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message); + } + } + + #region Emulated Responses + + private ProviderResponse GetEmulatedAudienceSegments(string requestId) + { + _logger.LogInformation("[Audience] Returning emulated audience segments | RequestId={RequestId}", requestId); + + var response = new AudienceSegmentsResponse + { + Affinity = new List + { + new() { Id = 80001, Name = "Sports & Fitness/Sports Fans", Type = "AFFINITY" }, + new() { Id = 80002, Name = "Sports & Fitness/Health & Fitness Buffs", Type = "AFFINITY" }, + new() { Id = 80003, Name = "Technology/Technophiles", Type = "AFFINITY" }, + new() { Id = 80004, Name = "Travel/Travel Buffs", Type = "AFFINITY" }, + new() { Id = 80005, Name = "Food & Dining/Foodies", Type = "AFFINITY" }, + new() { Id = 80006, Name = "Home & Garden/Home Decor Enthusiasts", Type = "AFFINITY" }, + new() { Id = 80007, Name = "Media & Entertainment/Movie Lovers", Type = "AFFINITY" }, + new() { Id = 80008, Name = "Media & Entertainment/Music Lovers", Type = "AFFINITY" }, + new() { Id = 80009, Name = "Shoppers/Value Shoppers", Type = "AFFINITY" }, + new() { Id = 80010, Name = "Shoppers/Luxury Shoppers", Type = "AFFINITY" }, + new() { Id = 80011, Name = "Lifestyles & Hobbies/Pet Lovers", Type = "AFFINITY" }, + new() { Id = 80012, Name = "Lifestyles & Hobbies/Outdoor Enthusiasts", Type = "AFFINITY" }, + new() { Id = 80013, Name = "News & Politics/Avid News Readers", Type = "AFFINITY" }, + new() { Id = 80014, Name = "Beauty & Wellness/Beauty Mavens", Type = "AFFINITY" }, + new() { Id = 80015, Name = "Vehicles & Transportation/Auto Enthusiasts", Type = "AFFINITY" }, + }, + InMarket = new List + { + new() { Id = 90001, Name = "Apparel & Accessories/Athletic Apparel", Type = "IN_MARKET" }, + new() { Id = 90002, Name = "Autos & Vehicles/Motor Vehicles (New)", Type = "IN_MARKET" }, + new() { Id = 90003, Name = "Autos & Vehicles/Motor Vehicles (Used)", Type = "IN_MARKET" }, + new() { Id = 90004, Name = "Business Services/Advertising & Marketing Services", Type = "IN_MARKET" }, + new() { Id = 90005, Name = "Consumer Electronics/Computers & Peripherals", Type = "IN_MARKET" }, + new() { Id = 90006, Name = "Consumer Electronics/Mobile Phones", Type = "IN_MARKET" }, + new() { Id = 90007, Name = "Education/Primary & Secondary Schools (K-12)", Type = "IN_MARKET" }, + new() { Id = 90008, Name = "Employment/Jobs", Type = "IN_MARKET" }, + new() { Id = 90009, Name = "Financial Services/Insurance/Auto Insurance", Type = "IN_MARKET" }, + new() { Id = 90010, Name = "Financial Services/Investment Services", Type = "IN_MARKET" }, + new() { Id = 90011, Name = "Home & Garden/Home Improvement", Type = "IN_MARKET" }, + new() { Id = 90012, Name = "Real Estate/Residential Properties", Type = "IN_MARKET" }, + new() { Id = 90013, Name = "Software/Business Software", Type = "IN_MARKET" }, + new() { Id = 90014, Name = "Travel/Hotels & Accommodations", Type = "IN_MARKET" }, + new() { Id = 90015, Name = "Travel/Air Travel", Type = "IN_MARKET" }, + }, + LifeEvents = new List + { + new() { Id = 70001, Name = "About to graduate college", Type = "LIFE_EVENT" }, + new() { Id = 70002, Name = "Getting married soon", Type = "LIFE_EVENT" }, + new() { Id = 70003, Name = "Recently married", Type = "LIFE_EVENT" }, + new() { Id = 70004, Name = "Moving soon", Type = "LIFE_EVENT" }, + new() { Id = 70005, Name = "Recently moved", Type = "LIFE_EVENT" }, + new() { Id = 70006, Name = "Purchasing a home soon", Type = "LIFE_EVENT" }, + new() { Id = 70007, Name = "Recently purchased a home", Type = "LIFE_EVENT" }, + new() { Id = 70008, Name = "Starting a new job", Type = "LIFE_EVENT" }, + new() { Id = 70009, Name = "Retiring soon", Type = "LIFE_EVENT" }, + new() { Id = 70010, Name = "Recently started a business", Type = "LIFE_EVENT" }, + }, + DetailedDemographics = new List + { + new() { Id = 60001, Name = "Parental status/Parents/Parents of infants (0-1 years)", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60002, Name = "Parental status/Parents/Parents of toddlers (1-3 years)", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60003, Name = "Parental status/Parents/Parents of preschoolers (3-5 years)", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60004, Name = "Parental status/Parents/Parents of grade schoolers (6-12 years)", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60005, Name = "Parental status/Parents/Parents of teens (13-17 years)", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60006, Name = "Marital status/Single", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60007, Name = "Marital status/In a relationship", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60008, Name = "Marital status/Married", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60009, Name = "Education/Current college students", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60010, Name = "Education/Bachelor's degree", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60011, Name = "Education/Advanced degree", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60012, Name = "Homeownership/Homeowners", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60013, Name = "Homeownership/Renters", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60014, Name = "Employment/Industry/Technology", Type = "DETAILED_DEMOGRAPHIC" }, + new() { Id = 60015, Name = "Employment/Company size/Large employers (10,000+)", Type = "DETAILED_DEMOGRAPHIC" }, + }, + RetrievedAt = DateTimeOffset.UtcNow + }; + + return ProviderResponse.Success(requestId, response); + } + + private ProviderResponse GetEmulatedGeoTargets(string query, string requestId) + { + _logger.LogInformation("[Audience] Returning emulated geo targets for '{Query}' | RequestId={RequestId}", + query, requestId); + + var allTargets = new List + { + new() { Id = 9061285, Name = "Huntington Beach", CanonicalName = "Huntington Beach,California,United States", TargetType = "City", CountryCode = "US" }, + new() { Id = 1013962, Name = "Orange County", CanonicalName = "Orange County,California,United States", TargetType = "County", CountryCode = "US" }, + new() { Id = 21137, Name = "California", CanonicalName = "California,United States", TargetType = "State", CountryCode = "US" }, + new() { Id = 1014221, Name = "Los Angeles", CanonicalName = "Los Angeles,California,United States", TargetType = "City", CountryCode = "US" }, + new() { Id = 1014218, Name = "Los Angeles County", CanonicalName = "Los Angeles County,California,United States", TargetType = "County", CountryCode = "US" }, + new() { Id = 9031936, Name = "Irvine", CanonicalName = "Irvine,California,United States", TargetType = "City", CountryCode = "US" }, + new() { Id = 9031935, Name = "Costa Mesa", CanonicalName = "Costa Mesa,California,United States", TargetType = "City", CountryCode = "US" }, + new() { Id = 9031938, Name = "Newport Beach", CanonicalName = "Newport Beach,California,United States", TargetType = "City", CountryCode = "US" }, + new() { Id = 9031937, Name = "Santa Ana", CanonicalName = "Santa Ana,California,United States", TargetType = "City", CountryCode = "US" }, + new() { Id = 1023191, Name = "New York", CanonicalName = "New York,New York,United States", TargetType = "City", CountryCode = "US" }, + new() { Id = 21167, Name = "New York", CanonicalName = "New York,United States", TargetType = "State", CountryCode = "US" }, + new() { Id = 1014895, Name = "Chicago", CanonicalName = "Chicago,Illinois,United States", TargetType = "City", CountryCode = "US" }, + new() { Id = 2840, Name = "United States", CanonicalName = "United States", TargetType = "Country", CountryCode = "US" }, + }; + + // Simple filter by query + var queryLower = query.ToLowerInvariant(); + var matches = allTargets + .Where(t => t.Name.ToLowerInvariant().Contains(queryLower) || + t.CanonicalName.ToLowerInvariant().Contains(queryLower)) + .Take(10) + .ToList(); + + return ProviderResponse.Success(requestId, new GeoTargetSearchResponse + { + Query = query, + Results = matches + }); + } + + #endregion +} diff --git a/GoogleApi/Services/GoogleAdsService.cs b/GoogleApi/Services/GoogleAdsService.cs index 37d7924..0e4a397 100644 --- a/GoogleApi/Services/GoogleAdsService.cs +++ b/GoogleApi/Services/GoogleAdsService.cs @@ -1,4 +1,4 @@ -using GoogleApi.Configuration; +using GoogleApi.Configuration; using GoogleApi.Models; using Microsoft.Extensions.Options; @@ -23,15 +23,24 @@ public sealed class GoogleAdsService { private readonly GoogleAdsConfig _config; private readonly GoogleAdsClientFactory _clientFactory; + private readonly AudienceService _audienceService; + private readonly ReportingService _reportingService; + private readonly KeywordForecastService _forecastService; private readonly ILogger _logger; public GoogleAdsService( IOptions config, GoogleAdsClientFactory clientFactory, + AudienceService audienceService, + ReportingService reportingService, + KeywordForecastService forecastService, ILogger logger) { _config = config.Value; _clientFactory = clientFactory; + _audienceService = audienceService; + _reportingService = reportingService; + _forecastService = forecastService; _logger = logger; } @@ -65,7 +74,20 @@ public sealed class GoogleAdsService "GetCampaignStats" => GetCampaignStats(request, requestId), "GetAccountStats" => GetAccountStats(request, requestId), + // Reporting (Campaign Intelligence) + "GetCampaignReport" => await _reportingService.GetCampaignReportAsync(request, context, requestId, ct), + "GetAccountReport" => await _reportingService.GetAccountReportAsync(request, context, requestId, ct), + "ListAccessibleCustomers" => await ListAccessibleCustomersAsync(context, requestId, ct), + "CreateCustomerClient" => await CreateCustomerClientAsync(request, context, requestId, ct), + + // Audience Operations + "GetAudienceSegments" => await _audienceService.GetAudienceSegmentsAsync(context, requestId, ct), + "SearchGeoTargets" => await _audienceService.SearchGeoTargetsAsync( + request.GetPayload(), context, requestId, ct), + + // Forecast + "KeywordForecast" => await ExecuteForecastAsync(request, context, requestId, ct), "" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"), _ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}") @@ -94,6 +116,14 @@ public sealed class GoogleAdsService timestamp = DateTimeOffset.UtcNow }); + private async Task ExecuteForecastAsync( + ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + var result = await _forecastService.ForecastAsync(payload, context, ct); + return ProviderResponse.Success(requestId, result); + } + private async Task CreateCampaignAsync( ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) { @@ -495,6 +525,100 @@ ORDER BY campaign.name"; } } + private async Task CreateCustomerClientAsync( + ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.AccountName)) + return ProviderResponse.Fail(requestId, "VALIDATION", "AccountName is required"); + + // For emulated mode + if (!_clientFactory.IsRealApiEnabled) + { + var fakeId = new Random().NextInt64(1000000000, 9999999999).ToString(); + _logger.LogInformation("[GoogleAds] EMULATED: Created customer client {AccountName} => {CustomerId}", + payload.AccountName, fakeId); + + return ProviderResponse.Success(requestId, new + { + customerId = fakeId, + accountName = payload.AccountName, + currencyCode = payload.CurrencyCode, + timeZone = payload.TimeZone, + emulated = true + }); + } + + try + { + // CreateCustomerClient runs against the MCC (manager account). + // The context.CustomerId should be the MCC ID. + var mccId = GoogleAdsClientFactory.NormalizeCustomerId( + context.CustomerId ?? request.TenantId ?? string.Empty); + + if (string.IsNullOrWhiteSpace(mccId)) + return ProviderResponse.Fail(requestId, "VALIDATION", + "TenantId (MCC customer ID) is required to create a sub-account"); + + // Force LoginCustomerId = MCC for this call + var mccContext = new GoogleAdsContext + { + CustomerId = mccId, + LoginCustomerId = mccId + }; + + GoogleAdsClient client = _clientFactory.CreateClient(mccContext); + + CustomerServiceClient customerService = + client.GetService(GAdsServices.V22.CustomerService); + + var customerClient = new Customer + { + DescriptiveName = payload.DescriptiveName ?? payload.AccountName, + CurrencyCode = payload.CurrencyCode, + TimeZone = payload.TimeZone + }; + + var response = await customerService.CreateCustomerClientAsync( + new CreateCustomerClientRequest + { + CustomerId = mccId, + CustomerClient = customerClient + }, + cancellationToken: ct); + + // Response contains the resource name like "customers/9336988646/customerClients/1234567890" + var newCustomerId = response.ResourceName?.Split('/').LastOrDefault() ?? response.ResourceName; + + _logger.LogInformation( + "[GoogleAds] Created customer client {AccountName} => {NewCustomerId} under MCC {MccId}", + payload.AccountName, newCustomerId, mccId); + + return ProviderResponse.Success(requestId, new + { + customerId = newCustomerId, + resourceName = response.ResourceName, + accountName = payload.AccountName, + descriptiveName = payload.DescriptiveName ?? payload.AccountName, + currencyCode = payload.CurrencyCode, + timeZone = payload.TimeZone, + mccId = mccId, + emulated = false + }); + } + catch (GoogleAdsException gex) + { + _logger.LogError(gex, "Google Ads API error creating customer client | RequestId={RequestId}", requestId); + return HandleGoogleAdsException(gex, requestId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create customer client via real API"); + return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message); + } + } + private static string GenerateId() => Guid.NewGuid().ToString("N")[..12]; private static AdvertisingChannelTypeEnum.Types.AdvertisingChannelType MapChannelType(CampaignType type) diff --git a/GoogleApi/Services/KeywordForecastService.cs b/GoogleApi/Services/KeywordForecastService.cs new file mode 100644 index 0000000..11d8e14 --- /dev/null +++ b/GoogleApi/Services/KeywordForecastService.cs @@ -0,0 +1,219 @@ +using GoogleApi.Configuration; +using GoogleApi.Models; +using Microsoft.Extensions.Options; + +using Google.Ads.GoogleAds.Lib; +using Google.Ads.GoogleAds.V22.Services; +using Google.Ads.GoogleAds.V22.Common; +using Google.Ads.GoogleAds.V22.Enums; +using Google.Ads.GoogleAds.V22.Resources; + +namespace GoogleApi.Services; + +using GAdsServices = global::Google.Ads.GoogleAds.Services; + +/// +/// Generates keyword-level forecast metrics via Google Ads KeywordPlanIdeaService. +/// Used by the wizard to show estimated performance before campaign creation. +/// +/// EnableRealApi=false → emulated estimates based on budget + keyword count. +/// EnableRealApi=true → calls GenerateKeywordForecastMetrics. +/// +public sealed class KeywordForecastService +{ + private readonly GoogleAdsConfig _config; + private readonly GoogleAdsClientFactory _clientFactory; + private readonly ILogger _logger; + + public KeywordForecastService( + IOptions config, + GoogleAdsClientFactory clientFactory, + ILogger logger) + { + _config = config.Value; + _clientFactory = clientFactory; + _logger = logger; + } + + public async Task ForecastAsync( + KeywordForecastPayload payload, + GoogleAdsContext context, + CancellationToken ct) + { + if (payload.Keywords.Count == 0) + { + _logger.LogWarning("[KeywordForecast] No keywords provided"); + return EmptyForecast(); + } + + if (_clientFactory.IsRealApiEnabled) + { + try + { + return await ForecastRealAsync(payload, context, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "[KeywordForecast] Real API failed, falling back to emulated"); + return ForecastEmulated(payload); + } + } + + return ForecastEmulated(payload); + } + + // ================================================================ + // Real API: GenerateKeywordForecastMetrics + // ================================================================ + + private async Task ForecastRealAsync( + KeywordForecastPayload payload, + GoogleAdsContext context, + CancellationToken ct) + { + _logger.LogInformation( + "[KeywordForecast] Real API | Keywords={Count} Budget={Budget} Geos={Geos}", + payload.Keywords.Count, payload.MonthlyBudget, payload.GeoTargetIds.Count); + + var client = _clientFactory.CreateClient(context); + var service = client.GetService(GAdsServices.V22.KeywordPlanIdeaService); + + // Build campaign to forecast + var campaign = new CampaignToForecast + { + KeywordPlanNetwork = KeywordPlanNetworkEnum.Types.KeywordPlanNetwork.GoogleSearch + }; + + // Geo targets + foreach (var geoId in payload.GeoTargetIds) + { + campaign.GeoModifiers.Add(new CriterionBidModifier + { + GeoTargetConstant = GeoTargetConstantName.Format(geoId.ToString()) + }); + } + + // Ad group with keywords (cap at 20 for sanity) + var adGroup = new ForecastAdGroup(); + foreach (var keyword in payload.Keywords.Take(20)) + { + adGroup.BiddableKeywords.Add(new BiddableKeyword + { + MaxCpcBidMicros = 2_000_000, // $2.00 default bid for simulation + Keyword = new KeywordInfo + { + Text = keyword, + MatchType = KeywordMatchTypeEnum.Types.KeywordMatchType.Broad + } + }); + } + campaign.AdGroups.Add(adGroup); + + var request = new GenerateKeywordForecastMetricsRequest + { + Campaign = campaign, + ForecastPeriod = new DateRange + { + StartDate = DateTime.UtcNow.ToString("yyyy-MM-dd"), + EndDate = DateTime.UtcNow.AddDays(payload.ForecastDays).ToString("yyyy-MM-dd") + }, + CustomerId = context.CustomerId + }; + + var response = await service.GenerateKeywordForecastMetricsAsync(request); + var m = response.CampaignForecastMetrics; + + // V22 SDK returns non-nullable primitives (0 when no data) + var impressions = m.Impressions; + var clicks = m.Clicks; + var costMicros = m.CostMicros; + var conversions = m.Conversions; + var avgCpcMicros = m.AverageCpcMicros; + + var cost = costMicros / 1_000_000m; + var avgCpc = avgCpcMicros / 1_000_000m; + var ctr = impressions > 0 ? clicks / impressions : 0; + var avgCpm = impressions > 0 ? (cost / (decimal)impressions) * 1000m : 0; + var cpa = conversions > 0 ? cost / (decimal)conversions : (decimal?)null; + + _logger.LogInformation( + "[KeywordForecast] Real result | Imp={Imp} Clicks={Clicks} Cost={Cost}", + impressions, clicks, cost); + + return new KeywordForecastResponse + { + Provider = "google", + Monthly = new ForecastEstimates + { + Impressions = impressions, + Clicks = clicks, + Cost = cost, + Conversions = conversions + }, + Metrics = new ForecastMetrics + { + AvgCpc = avgCpc, + AvgCpm = avgCpm, + Ctr = ctr, + EstimatedCpa = cpa + }, + Confidence = "medium", + DataSource = "keywordForecast" + }; + } + + // ================================================================ + // Emulated: budget-proportional estimates with realistic variance + // ================================================================ + + private KeywordForecastResponse ForecastEmulated(KeywordForecastPayload payload) + { + _logger.LogInformation( + "[KeywordForecast] Emulated | Keywords={Count} Budget={Budget}", + payload.Keywords.Count, payload.MonthlyBudget); + + // Realistic Search ranges for SMB tier + var keywordFactor = Math.Min(payload.Keywords.Count, 20) / 20.0; + var baseCpc = 2.50m - (decimal)(keywordFactor * 1.20); // $1.30 – $2.50 + + var budget = payload.MonthlyBudget; + var clicks = budget > 0 ? (double)(budget / baseCpc) : 0; + var impressions = clicks / 0.045; // ~4.5% CTR + var conversions = clicks * 0.035; // ~3.5% conv rate + var ctr = impressions > 0 ? clicks / impressions : 0; + var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0; + var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null; + + // Seeded variance (±15%) — same inputs → same output, but not canned-looking + var rng = new Random((int)(budget * 100) + payload.Keywords.Count); + var v = 0.85 + (rng.NextDouble() * 0.30); + + return new KeywordForecastResponse + { + Provider = "google", + Monthly = new ForecastEstimates + { + Impressions = Math.Round(impressions * v), + Clicks = Math.Round(clicks * v), + Cost = Math.Round(budget * (decimal)v, 2), + Conversions = Math.Round(conversions * v, 1) + }, + Metrics = new ForecastMetrics + { + AvgCpc = Math.Round(baseCpc, 2), + AvgCpm = Math.Round((decimal)cpm, 2), + Ctr = Math.Round(ctr, 4), + EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null + }, + Confidence = "low", + DataSource = "emulated" + }; + } + + private static KeywordForecastResponse EmptyForecast() => new() + { + Provider = "google", + Confidence = "none", + DataSource = "none" + }; +} diff --git a/GoogleApi/Services/ReportingService.cs b/GoogleApi/Services/ReportingService.cs new file mode 100644 index 0000000..71c4e07 --- /dev/null +++ b/GoogleApi/Services/ReportingService.cs @@ -0,0 +1,382 @@ +using GoogleApi.Configuration; +using GoogleApi.Models; +using Microsoft.Extensions.Options; + +using Google.Ads.GoogleAds; +using Google.Ads.GoogleAds.Lib; +using Google.Ads.GoogleAds.V22.Errors; +using Google.Ads.GoogleAds.V22.Services; + +namespace GoogleApi.Services; + +using GAdsServices = global::Google.Ads.GoogleAds.Services; + +/// +/// Reporting service for pulling campaign performance metrics from Google Ads. +/// Supports both real API calls and emulated responses for development. +/// +/// Operations: +/// - GetCampaignReport: Daily metrics for a specific campaign +/// - GetAccountReport: Daily metrics across all campaigns in an account +/// - GetCampaignList: Campaign status/budget summary (lightweight) +/// +public sealed class ReportingService +{ + private readonly GoogleAdsConfig _config; + private readonly GoogleAdsClientFactory _clientFactory; + private readonly ILogger _logger; + + public ReportingService( + IOptions config, + GoogleAdsClientFactory clientFactory, + ILogger logger) + { + _config = config.Value; + _clientFactory = clientFactory; + _logger = logger; + } + + /// + /// Get daily campaign performance report. + /// Returns impressions, clicks, spend, conversions, conversion value per day. + /// + public async Task GetCampaignReportAsync( + ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.CampaignId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"); + + var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd"); + var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd"); + + if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId)) + return await GetCampaignReportRealAsync(payload.CampaignId, startDate, endDate, context, requestId, ct); + + return GetCampaignReportEmulated(payload.CampaignId, startDate, endDate, requestId); + } + + /// + /// Get daily account-level performance report across all campaigns. + /// Used for syncing metrics into tbPerformanceMetric. + /// + public async Task GetAccountReportAsync( + ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + var startDate = payload.StartDate ?? DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd"); + var endDate = payload.EndDate ?? DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd"); + + if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId)) + return await GetAccountReportRealAsync(startDate, endDate, context, requestId, ct); + + return GetAccountReportEmulated(startDate, endDate, requestId); + } + + // ════════════════════════════════════════════════ + // Real API Implementation + // ════════════════════════════════════════════════ + + private async Task GetCampaignReportRealAsync( + string campaignId, string startDate, string endDate, + GoogleAdsContext context, string requestId, CancellationToken ct) + { + try + { + var client = _clientFactory.CreateClient(context); + var gaService = client.GetService(GAdsServices.V22.GoogleAdsService); + + // Extract numeric campaign ID from resource name if needed + var numericId = campaignId.Contains('/') ? campaignId.Split('/').Last() : campaignId; + + var query = $@" + SELECT + campaign.id, + campaign.name, + campaign.status, + segments.date, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value, + metrics.all_conversions, + metrics.ctr, + metrics.average_cpc + FROM campaign + WHERE campaign.id = {numericId} + AND segments.date BETWEEN '{startDate}' AND '{endDate}' + ORDER BY segments.date"; + + var rows = new List(); + + var searchRequest = new SearchGoogleAdsRequest + { + CustomerId = context.CustomerId, + Query = query + }; + + var response = gaService.Search(searchRequest); + + foreach (var row in response) + { + rows.Add(new + { + date = row.Segments.Date, + campaignId = row.Campaign.Id, + campaignName = row.Campaign.Name, + campaignStatus = row.Campaign.Status.ToString(), + impressions = row.Metrics.Impressions, + clicks = row.Metrics.Clicks, + costMicros = row.Metrics.CostMicros, + spend = row.Metrics.CostMicros / 1_000_000.0, + conversions = row.Metrics.Conversions, + conversionValue = row.Metrics.ConversionsValue, + allConversions = row.Metrics.AllConversions, + ctr = row.Metrics.Ctr, + averageCpcMicros = row.Metrics.AverageCpc + }); + } + + _logger.LogInformation( + "[Reporting] Retrieved {Count} rows for campaign {CampaignId} | RequestId={RequestId}", + rows.Count, campaignId, requestId); + + return ProviderResponse.Success(requestId, new + { + campaignId, + dateRange = new { start = startDate, end = endDate }, + rows, + rowCount = rows.Count, + emulated = false + }); + } + catch (GoogleAdsException gex) + { + _logger.LogError(gex, "[Reporting] Google Ads error for campaign {CampaignId}", campaignId); + return HandleGoogleAdsException(gex, requestId); + } + catch (Exception ex) + { + _logger.LogError(ex, "[Reporting] Error fetching campaign report"); + return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message); + } + } + + private async Task GetAccountReportRealAsync( + string startDate, string endDate, + GoogleAdsContext context, string requestId, CancellationToken ct) + { + try + { + var client = _clientFactory.CreateClient(context); + var gaService = client.GetService(GAdsServices.V22.GoogleAdsService); + + var query = $@" + SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + segments.date, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value, + metrics.all_conversions + FROM campaign + WHERE segments.date BETWEEN '{startDate}' AND '{endDate}' + AND campaign.status != 'REMOVED' + ORDER BY segments.date, campaign.id"; + + var rows = new List(); + + var searchRequest = new SearchGoogleAdsRequest + { + CustomerId = context.CustomerId, + Query = query + }; + + var response = gaService.Search(searchRequest); + + foreach (var row in response) + { + rows.Add(new + { + date = row.Segments.Date, + campaignId = row.Campaign.Id, + campaignName = row.Campaign.Name, + campaignStatus = row.Campaign.Status.ToString(), + channelType = row.Campaign.AdvertisingChannelType.ToString(), + impressions = row.Metrics.Impressions, + clicks = row.Metrics.Clicks, + costMicros = row.Metrics.CostMicros, + spend = row.Metrics.CostMicros / 1_000_000.0, + conversions = row.Metrics.Conversions, + conversionValue = row.Metrics.ConversionsValue, + allConversions = row.Metrics.AllConversions + }); + } + + _logger.LogInformation( + "[Reporting] Retrieved {Count} rows for account {CustomerId} | RequestId={RequestId}", + rows.Count, context.CustomerId, requestId); + + return ProviderResponse.Success(requestId, new + { + customerId = context.CustomerId, + dateRange = new { start = startDate, end = endDate }, + rows, + rowCount = rows.Count, + emulated = false + }); + } + catch (GoogleAdsException gex) + { + _logger.LogError(gex, "[Reporting] Google Ads error for account {CustomerId}", context.CustomerId); + return HandleGoogleAdsException(gex, requestId); + } + catch (Exception ex) + { + _logger.LogError(ex, "[Reporting] Error fetching account report"); + return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message); + } + } + + // ════════════════════════════════════════════════ + // Emulated Responses + // ════════════════════════════════════════════════ + + private ProviderResponse GetCampaignReportEmulated( + string campaignId, string startDate, string endDate, string requestId) + { + _logger.LogInformation("[Reporting] EMULATED: Campaign report for {CampaignId}", campaignId); + + var rows = GenerateEmulatedDailyRows(startDate, endDate); + + return ProviderResponse.Success(requestId, new + { + campaignId, + dateRange = new { start = startDate, end = endDate }, + rows, + rowCount = rows.Count, + emulated = true + }); + } + + private ProviderResponse GetAccountReportEmulated( + string startDate, string endDate, string requestId) + { + _logger.LogInformation("[Reporting] EMULATED: Account report"); + + // Generate rows for 3 emulated campaigns + var allRows = new List(); + var campaigns = new[] + { + new { id = "emu_camp_001", name = "Search - Brand Terms", channel = "SEARCH" }, + new { id = "emu_camp_002", name = "Display - Awareness", channel = "DISPLAY" }, + new { id = "emu_camp_003", name = "PMax - Conversions", channel = "PERFORMANCE_MAX" } + }; + + foreach (var camp in campaigns) + { + var rows = GenerateEmulatedDailyRows(startDate, endDate); + foreach (var row in rows) + { + // Merge campaign info into each row + var dict = new Dictionary + { + ["campaignId"] = camp.id, + ["campaignName"] = camp.name, + ["campaignStatus"] = "ENABLED", + ["channelType"] = camp.channel + }; + + // Merge the row properties + var rowJson = System.Text.Json.JsonSerializer.Serialize(row); + var rowDict = System.Text.Json.JsonSerializer.Deserialize>(rowJson); + if (rowDict != null) + foreach (var kv in rowDict) dict[kv.Key] = kv.Value; + + allRows.Add(dict); + } + } + + return ProviderResponse.Success(requestId, new + { + customerId = "emulated", + dateRange = new { start = startDate, end = endDate }, + rows = allRows, + rowCount = allRows.Count, + emulated = true + }); + } + + private static List GenerateEmulatedDailyRows(string startDate, string endDate) + { + var rows = new List(); + var rng = new Random(42); // deterministic seed for consistent emulation + + if (!DateTime.TryParse(startDate, out var start)) start = DateTime.UtcNow.AddDays(-30); + if (!DateTime.TryParse(endDate, out var end)) end = DateTime.UtcNow.AddDays(-1); + + for (var date = start; date <= end; date = date.AddDays(1)) + { + var impressions = rng.Next(800, 3500); + var clicks = (int)(impressions * (0.015 + rng.NextDouble() * 0.04)); + var costMicros = (long)(clicks * (450_000 + rng.Next(0, 300_000))); + var conversions = Math.Round(clicks * (0.03 + rng.NextDouble() * 0.05), 2); + var conversionValue = Math.Round(conversions * (25 + rng.NextDouble() * 75), 2); + + rows.Add(new + { + date = date.ToString("yyyy-MM-dd"), + impressions, + clicks, + costMicros, + spend = Math.Round(costMicros / 1_000_000.0, 2), + conversions, + conversionValue, + allConversions = Math.Round(conversions * 1.15, 2), + ctr = impressions > 0 ? Math.Round((double)clicks / impressions, 4) : 0.0, + averageCpcMicros = clicks > 0 ? costMicros / clicks : 0L + }); + } + + return rows; + } + + // ════════════════════════════════════════════════ + // Error Handling + // ════════════════════════════════════════════════ + + private static ProviderResponse HandleGoogleAdsException(GoogleAdsException gex, string requestId) + { + var errorDetails = gex.Failure?.Errors?.Select(e => new + { + errorCode = e.ErrorCode?.ToString(), + message = e.Message, + trigger = e.Trigger?.StringValue, + location = e.Location?.FieldPathElements?.Select(f => f.FieldName).ToArray() + }).ToList(); + + return ProviderResponse.Fail(requestId, "GOOGLE_ADS_ERROR", gex.Message, new + { + googleRequestId = gex.RequestId, + errors = errorDetails + }); + } +} + +/// +/// Payload for reporting operations. +/// +public sealed class ReportingPayload +{ + public string? CampaignId { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } +} diff --git a/IntelligenceApi/Controllers/DemographicsController.cs b/IntelligenceApi/Controllers/DemographicsController.cs new file mode 100644 index 0000000..28dff63 --- /dev/null +++ b/IntelligenceApi/Controllers/DemographicsController.cs @@ -0,0 +1,48 @@ +using IntelligenceApi.Engines; +using IntelligenceApi.Models; +using Microsoft.AspNetCore.Mvc; + +namespace IntelligenceApi.Controllers; + +/// +/// Demographics analysis endpoint. +/// +/// Called exclusively by the Gateway's IntelligenceApiClient after it fetches +/// raw census data from the database. This container derives audience +/// recommendations from the raw data — age chips, income tiers, insights. +/// +/// POST /api/demographics/analyze +/// +[ApiController] +[Route("api/demographics")] +public sealed class DemographicsController : ControllerBase +{ + private readonly DemographicsAnalyzer _analyzer; + private readonly ILogger _log; + + public DemographicsController(DemographicsAnalyzer analyzer, ILogger log) + { + _analyzer = analyzer; + _log = log; + } + + [HttpPost("analyze")] + public IActionResult Analyze([FromBody] DemographicAnalysisRequest? request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Zcta)) + return BadRequest(new { ok = false, error = "zcta and census data are required" }); + + _log.LogInformation("[Demographics] Analyze | ZCTA={Zcta}", request.Zcta); + + try + { + var result = _analyzer.Analyze(request); + return Ok(result); + } + catch (Exception ex) + { + _log.LogError(ex, "[Demographics] Analysis error | ZCTA={Zcta}", request.Zcta); + return StatusCode(500, new { ok = false, error = "Demographics analysis error" }); + } + } +} diff --git a/IntelligenceApi/Controllers/InternalController.cs b/IntelligenceApi/Controllers/InternalController.cs new file mode 100644 index 0000000..2318506 --- /dev/null +++ b/IntelligenceApi/Controllers/InternalController.cs @@ -0,0 +1,122 @@ +using IntelligenceApi.Engines; +using IntelligenceApi.Models; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace IntelligenceApi.Controllers; + +/// +/// Internal execution endpoint — the single entry point for Gateway-routed requests. +/// +/// Called exclusively by the Gateway's ExecutionService via: +/// POST /internal/execute +/// Headers: X-Internal-Key, X-Request-Id +/// Body: { "operation": "Ping" | "SpendDistribution", "payload": { ... } } +/// +/// Operations: +/// Ping — liveness check, no payload required +/// SpendDistribution — routes to EngineRouter; payload maps to SpendDistributionRequest +/// +[ApiController] +[Route("internal/execute")] +public sealed class InternalController : ControllerBase +{ + private readonly EngineRouter _router; + private readonly ILogger _log; + + private static readonly JsonSerializerOptions _jsonOpts = + new(JsonSerializerDefaults.Web); + + public InternalController(EngineRouter router, ILogger log) + { + _router = router; + _log = log; + } + + [HttpPost] + public async Task Execute( + [FromBody] InternalExecuteRequest? request, + CancellationToken ct) + { + if (request == null) + return BadRequest(new { ok = false, error = "Request body required" }); + + var requestId = request.RequestId ?? HttpContext.Request.Headers["X-Request-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString("N"); + var operation = (request.Operation ?? "").Trim(); + + _log.LogInformation( + "[Internal] Operation={Operation} RequestId={RequestId}", + operation, requestId); + + return operation.ToLowerInvariant() switch + { + "ping" => Ok(new { ok = true, requestId, service = "IntelligenceApi", timestamp = DateTimeOffset.UtcNow }), + "spenddistribution" => await SpendDistribution(request.Payload, requestId, ct), + _ => BadRequest(new { ok = false, requestId, error = $"Unknown operation: '{operation}'" }) + }; + } + + // ── Spend Distribution ─────────────────────────────────────────────────── + + private async Task SpendDistribution( + JsonElement? payload, + string requestId, + CancellationToken ct) + { + if (payload == null || payload.Value.ValueKind == JsonValueKind.Null) + return BadRequest(new { ok = false, requestId, error = "Payload required for SpendDistribution" }); + + SpendDistributionRequest? distRequest; + try + { + distRequest = JsonSerializer.Deserialize( + payload.Value.GetRawText(), _jsonOpts); + } + catch (Exception ex) + { + _log.LogWarning("[Internal] Payload deserialize failed | RequestId={RequestId} Error={Error}", + requestId, ex.Message); + return BadRequest(new { ok = false, requestId, error = "Invalid payload shape for SpendDistribution" }); + } + + if (distRequest == null) + return BadRequest(new { ok = false, requestId, error = "Payload required for SpendDistribution" }); + + if (distRequest.MonthlyBudget <= 0) + return BadRequest(new { ok = false, requestId, error = "monthlyBudget must be greater than zero" }); + + if (distRequest.Keywords.Count == 0) + return BadRequest(new { ok = false, requestId, error = "At least one keyword is required" }); + + try + { + var engine = _router.Resolve(distRequest.ClientCategory); + var response = await engine.RecommendAsync(distRequest, ct); + + _log.LogInformation( + "[Internal] SpendDistribution OK | RequestId={RequestId} Engine={Engine} Channels={N}", + requestId, response.Metadata.Engine, response.Channels.Count); + + return Ok(response); + } + catch (Exception ex) + { + _log.LogError(ex, "[Internal] SpendDistribution error | RequestId={RequestId}", requestId); + return StatusCode(500, new { ok = false, requestId, error = "Intelligence service error" }); + } + } +} + +// ── Request model ──────────────────────────────────────────────────────────── + +/// +/// Shape sent by Gateway's ExecutionService to every provider container. +/// Matches the object built in ExecutionService.BuildProviderRequest(). +/// +public sealed class InternalExecuteRequest +{ + public string? Operation { get; set; } + public string? RequestId { get; set; } + public string? TenantId { get; set; } + public JsonElement? Payload { get; set; } +} diff --git a/IntelligenceApi/Controllers/SpendDistributionController.cs b/IntelligenceApi/Controllers/SpendDistributionController.cs new file mode 100644 index 0000000..5cb33be --- /dev/null +++ b/IntelligenceApi/Controllers/SpendDistributionController.cs @@ -0,0 +1,67 @@ +using IntelligenceApi.Engines; +using IntelligenceApi.Models; +using Microsoft.AspNetCore.Mvc; + +namespace IntelligenceApi.Controllers; + +/// +/// Spend distribution endpoint — the single public surface of IntelligenceApi. +/// +/// Called exclusively by the Gateway (never directly by the client portal). +/// The Gateway injects clientCategory from ClientContext before forwarding. +/// +/// POST /api/spend-distribution +/// +[ApiController] +[Route("api/spend-distribution")] +public sealed class SpendDistributionController : ControllerBase +{ + private readonly EngineRouter _router; + private readonly ILogger _log; + + public SpendDistributionController(EngineRouter router, ILogger log) + { + _router = router; + _log = log; + } + + /// + /// Generate a spend distribution recommendation. + /// clientCategory in the request body determines which engine runs. + /// + [HttpPost] + public async Task Recommend( + [FromBody] SpendDistributionRequest? request, + CancellationToken ct) + { + if (request == null) + return BadRequest(new { ok = false, error = "Request body required" }); + + if (request.MonthlyBudget <= 0) + return BadRequest(new { ok = false, error = "monthlyBudget must be greater than zero" }); + + if (request.Keywords.Count == 0) + return BadRequest(new { ok = false, error = "At least one keyword is required" }); + + _log.LogInformation( + "[SpendDistribution] Request | Category={Category} Budget={Budget} Objective={Obj}", + request.ClientCategory, request.MonthlyBudget, request.Objective); + + try + { + var engine = _router.Resolve(request.ClientCategory); + var response = await engine.RecommendAsync(request, ct); + + _log.LogInformation( + "[SpendDistribution] OK | Engine={Engine} Channels={N}", + response.Metadata.Engine, response.Channels.Count); + + return Ok(response); + } + catch (Exception ex) + { + _log.LogError(ex, "[SpendDistribution] Unhandled error"); + return StatusCode(500, new { ok = false, error = "Intelligence service error" }); + } + } +} diff --git a/IntelligenceApi/Engines/DemographicsAnalyzer.cs b/IntelligenceApi/Engines/DemographicsAnalyzer.cs new file mode 100644 index 0000000..df02ccd --- /dev/null +++ b/IntelligenceApi/Engines/DemographicsAnalyzer.cs @@ -0,0 +1,91 @@ +using IntelligenceApi.Models; + +namespace IntelligenceApi.Engines; + +/// +/// Derives audience recommendations from raw census data for a ZCTA. +/// +/// This logic was previously embedded in the Gateway's DemographicsController +/// as BuildMarketAnalysis(). It belongs here — IntelligenceApi owns all +/// recommendation and analysis logic; the Gateway is a thin proxy. +/// +/// Registered as a singleton: stateless, no IO. +/// +public sealed class DemographicsAnalyzer +{ + public DemographicAnalysisResponse Analyze(DemographicAnalysisRequest request) + { + var c = request.Census; + var zcta = request.Zcta; + + // ── Age skew ────────────────────────────────────────────────────────── + var youngPct = c.Pct18to24 + c.Pct25to34; + var maturePct = c.Pct55to64 + c.Pct65plus; + + string ageSkew; + if (youngPct > maturePct + 10) ageSkew = "young"; + else if (maturePct > youngPct + 10) ageSkew = "mature"; + else ageSkew = "balanced"; + + // ── Recommended age chips ───────────────────────────────────────────── + // Include brackets with meaningful population share. + var ageRanges = new List(); + if (c.Pct18to24 >= 10) ageRanges.Add("AGE_18_24"); + if (c.Pct25to34 >= 12) ageRanges.Add("AGE_25_34"); + if (c.Pct35to44 >= 12) ageRanges.Add("AGE_35_44"); + if (c.Pct45to54 >= 12) ageRanges.Add("AGE_45_54"); + if (c.Pct55to64 >= 10) ageRanges.Add("AGE_55_64"); + if (c.Pct65plus >= 12) ageRanges.Add("AGE_65_UP"); + + // Fallback: if no bracket clears the threshold, default to prime brackets + if (ageRanges.Count == 0) + { + ageRanges.Add("AGE_25_34"); + ageRanges.Add("AGE_35_44"); + } + + // ── Recommended income chips ────────────────────────────────────────── + var incomes = c.MedianIncome switch + { + > 100_000 => new List { "TOP_10", "TOP_11_20" }, + > 75_000 => new List { "TOP_11_20", "TOP_21_30" }, + > 50_000 => new List { "TOP_21_30", "TOP_31_40" }, + _ => new List { "TOP_41_50", "LOWER_50" } + }; + + // ── Human-readable insights ─────────────────────────────────────────── + var insights = new List(); + + if (c.TotalPopulation > 0) insights.Add($"{c.TotalPopulation:N0} people"); + if (c.MedianIncome > 0) insights.Add($"Median income ${c.MedianIncome:N0}"); + if (c.PctBachelorPlus > 0) insights.Add($"{c.PctBachelorPlus}% college-educated"); + + if (c.PctOwnerOccupied > 55) insights.Add($"{c.PctOwnerOccupied}% homeowners"); + else if (c.PctRenterOccupied > 55) insights.Add($"{c.PctRenterOccupied}% renters"); + + if (c.PctFamilyHouseholds > 60) insights.Add($"{c.PctFamilyHouseholds}% families"); + else if (c.PctLivingAlone > 35) insights.Add($"{c.PctLivingAlone}% single-person households"); + + insights.Add(ageSkew switch + { + "young" => "Skews younger (18–34)", + "mature" => "Skews older (55+)", + _ => "Balanced age distribution" + }); + + return new DemographicAnalysisResponse + { + Ok = true, + Zcta = zcta, + Census = c, + Recommendations = new AudienceRecommendations + { + AgeRanges = ageRanges, + Incomes = incomes, + AgeSkew = ageSkew, + MarketScope = "local" // single ZIP is always local scope + }, + Insights = insights + }; + } +} diff --git a/IntelligenceApi/Engines/EngineRouter.cs b/IntelligenceApi/Engines/EngineRouter.cs new file mode 100644 index 0000000..b917179 --- /dev/null +++ b/IntelligenceApi/Engines/EngineRouter.cs @@ -0,0 +1,75 @@ +using IntelligenceApi.Engines.Franchisee; +using IntelligenceApi.Engines.Franchisor; +using IntelligenceApi.Engines.General; + +namespace IntelligenceApi.Engines; + +/// +/// Routes incoming spend distribution requests to the correct engine +/// based on clientCategory. +/// +/// ADDING A NEW ENGINE: +/// 1. Create a folder under Engines/ (e.g. Engines/FoodFranchisee/) +/// 2. Implement ISpendDistributionEngine +/// 3. Register in Program.cs +/// 4. Add the category string to the routing table below +/// +/// The General engine is the fallback for any unrecognised category, +/// ensuring existing clients are never broken by new category additions. +/// +public sealed class EngineRouter +{ + private readonly GeneralEngine _general; + private readonly FranchiseeEngine _franchisee; + private readonly FranchisorEngine _franchisor; + private readonly ILogger _logger; + + public EngineRouter( + GeneralEngine general, + FranchiseeEngine franchisee, + FranchisorEngine franchisor, + ILogger logger) + { + _general = general; + _franchisee = franchisee; + _franchisor = franchisor; + _logger = logger; + } + + public ISpendDistributionEngine Resolve(string? clientCategory) + { + var engine = (clientCategory ?? "General").Trim() switch + { + // ── Exact category matches ────────────────────────────── + "General" => (ISpendDistributionEngine)_general, + "Franchisee" => _franchisee, + "Franchisor" => _franchisor, + + // ── Future sub-categories route to their parent stub + // until a dedicated engine is built. + // e.g. "FoodFranchisee" => _foodFranchisee (not yet registered) + // falls through to Franchisee as the nearest match. + var c when c.EndsWith("Franchisee", StringComparison.OrdinalIgnoreCase) + => _franchisee, + var c when c.EndsWith("Franchisor", StringComparison.OrdinalIgnoreCase) + => _franchisor, + + // ── Unknown / unrecognised — safe fallback ────────────── + var c => LogAndFallback(c) + }; + + _logger.LogInformation( + "[EngineRouter] Category={Category} → Engine={Engine}", + clientCategory, engine.EngineName); + + return engine; + } + + private ISpendDistributionEngine LogAndFallback(string category) + { + _logger.LogWarning( + "[EngineRouter] Unrecognised category '{Category}' — falling back to GeneralEngine", + category); + return _general; + } +} diff --git a/IntelligenceApi/Engines/Franchisee/FranchiseeEngine.cs b/IntelligenceApi/Engines/Franchisee/FranchiseeEngine.cs new file mode 100644 index 0000000..2a907f5 --- /dev/null +++ b/IntelligenceApi/Engines/Franchisee/FranchiseeEngine.cs @@ -0,0 +1,59 @@ +using IntelligenceApi.Models; + +namespace IntelligenceApi.Engines.Franchisee; + +/// +/// Spend distribution engine for Franchisee clients. +/// +/// CURRENT STATUS: Stub — delegates to GeneralEngine logic. +/// Returns General recommendations with engine name set to "Franchisee" +/// so billing and logging correctly identify the tier. +/// +/// PLANNED: Premium AI-driven recommendations incorporating: +/// - Proximity analysis to sibling franchisee locations +/// (avoid cannibalisation, identify territory gaps) +/// - Local competitor density from Google Maps / Places API +/// - Demographic fit scoring per geo zone +/// - Franchisor brand guidelines (approved channels, spend floors) +/// - Historical performance benchmarks across the franchise network +/// - Dayparting patterns specific to the franchise category +/// (e.g. lunch peaks for food, weekend spikes for home services) +/// +/// IMPLEMENTATION PATH: +/// 1. Inject IFranchiseeDataService (location DB + geo queries) +/// 2. Inject ICompetitorIntelligenceService (Places API or similar) +/// 3. Replace scoring weights with category-trained model output +/// 4. Surface franchise-specific highlights in DistributionRecommendation +/// +public sealed class FranchiseeEngine : ISpendDistributionEngine +{ + private readonly General.GeneralEngine _general; + private readonly ILogger _logger; + + public string EngineName => "Franchisee"; + + public FranchiseeEngine(General.GeneralEngine general, ILogger logger) + { + _general = general; + _logger = logger; + } + + public async Task RecommendAsync( + SpendDistributionRequest request, CancellationToken ct) + { + _logger.LogInformation( + "[FranchiseeEngine] Stub — delegating to GeneralEngine | Budget={Budget}", + request.MonthlyBudget); + + // Delegate to General for now + var response = await _general.RecommendAsync(request, ct); + + // Override engine name so billing / logging is correct + response.Metadata.Engine = EngineName; + + // TODO: Augment recommendation with franchise-specific insights + // once data services are wired in. + + return response; + } +} diff --git a/IntelligenceApi/Engines/Franchisor/FranchisorEngine.cs b/IntelligenceApi/Engines/Franchisor/FranchisorEngine.cs new file mode 100644 index 0000000..cc56705 --- /dev/null +++ b/IntelligenceApi/Engines/Franchisor/FranchisorEngine.cs @@ -0,0 +1,58 @@ +using IntelligenceApi.Models; + +namespace IntelligenceApi.Engines.Franchisor; + +/// +/// Spend distribution engine for Franchisor (brand / network owner) clients. +/// +/// CURRENT STATUS: Stub — delegates to GeneralEngine logic. +/// +/// PLANNED: Network-level AI recommendations incorporating: +/// - Co-op budget allocation across franchisee network +/// (brand-level national vs. local tier split) +/// - Network-wide performance benchmarks and outlier detection +/// - Territory coverage analysis — identifying under-served markets +/// - Brand consistency enforcement across provider configurations +/// - Consolidated reporting roll-up across all franchisee accounts +/// - Seasonal and promotional campaign coordination +/// - Franchisee performance ranking to guide co-op investment priority +/// +/// DISTINCTION FROM FRANCHISEE ENGINE: +/// Franchisee = single-location optimisation (local) +/// Franchisor = multi-location orchestration (network-wide) +/// +/// IMPLEMENTATION PATH: +/// 1. Inject IFranchiseeNetworkService (all locations, territories, tiers) +/// 2. Inject INetworkPerformanceService (aggregate metrics across accounts) +/// 3. Implement network-aware allocation: national brand % + local co-op % +/// 4. Surface network health summary in DistributionRecommendation +/// +public sealed class FranchisorEngine : ISpendDistributionEngine +{ + private readonly General.GeneralEngine _general; + private readonly ILogger _logger; + + public string EngineName => "Franchisor"; + + public FranchisorEngine(General.GeneralEngine general, ILogger logger) + { + _general = general; + _logger = logger; + } + + public async Task RecommendAsync( + SpendDistributionRequest request, CancellationToken ct) + { + _logger.LogInformation( + "[FranchisorEngine] Stub — delegating to GeneralEngine | Budget={Budget}", + request.MonthlyBudget); + + var response = await _general.RecommendAsync(request, ct); + response.Metadata.Engine = EngineName; + + // TODO: Replace with network-aware allocation once + // IFranchiseeNetworkService is implemented. + + return response; + } +} diff --git a/IntelligenceApi/Engines/General/GeneralEngine.cs b/IntelligenceApi/Engines/General/GeneralEngine.cs new file mode 100644 index 0000000..665a790 --- /dev/null +++ b/IntelligenceApi/Engines/General/GeneralEngine.cs @@ -0,0 +1,478 @@ +using IntelligenceApi.Models; +using System.Diagnostics; +using System.Text.Json; + +namespace IntelligenceApi.Engines.General; + +/// +/// Default spend distribution engine for General (small business) clients. +/// +/// This is a direct transplant of ForecastService from the Gateway. +/// Behavior is identical to the original — existing clients see no change. +/// +/// Algorithm: +/// 1. Fan out to provider APIs in parallel (Google live, others emulated) +/// 2. Normalize metrics across providers +/// 3. Score each channel using objective-weighted metrics +/// 4. Derive allocation percentages (min 15%, max 85%) +/// 5. Return sorted channel estimates with recommendation text +/// +/// No AI cost — runs entirely on rules + provider data. +/// +public sealed class GeneralEngine : ISpendDistributionEngine +{ + private readonly IHttpClientFactory _http; + private readonly ILogger _logger; + + private const int MIN_ALLOCATION = 15; + private const int MAX_ALLOCATION = 85; + + private static readonly JsonSerializerOptions _jsonOpts = + new(JsonSerializerDefaults.Web); + + public string EngineName => "General"; + + public GeneralEngine(IHttpClientFactory http, ILogger logger) + { + _http = http; + _logger = logger; + } + + public async Task RecommendAsync( + SpendDistributionRequest request, CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + var channels = request.Channels ?? new List { "google_ads" }; + var weights = ObjectiveWeights.For(request.Objective); + + _logger.LogInformation( + "[GeneralEngine] Starting | Objective={Obj} Budget={Budget} Channels={Ch}", + request.Objective, request.MonthlyBudget, string.Join(",", channels)); + + // ── Fan out to providers in parallel ── + var tasks = new Dictionary>(); + foreach (var channel in channels) + { + tasks[channel] = channel switch + { + "google_ads" => FetchGoogleAsync(request, ct), + "meta" => FetchMetaAsync(request, ct), + _ => Task.FromResult(TemplateForecast(channel)) + }; + } + + await Task.WhenAll(tasks.Values); + + var results = tasks.ToDictionary(t => t.Key, t => t.Value.Result); + + // ── Score and allocate ── + var scored = ScoreChannels(results, weights); + var allocations = DeriveAllocations(scored); + + // ── Build response ── + var channelAllocations = new List(); + foreach (var (channel, result) in results) + { + var pct = allocations[channel]; + var allocated = Math.Round(request.MonthlyBudget * pct / 100m, 2); + + channelAllocations.Add(new ChannelAllocation + { + Provider = channel, + AllocationPercent = pct, + AllocatedBudget = allocated, + Estimates = new AllocationMetrics + { + Impressions = result.Impressions, + Reach = result.Reach, + Clicks = result.Clicks, + Conversions = result.Conversions, + AvgCpc = result.AvgCpc, + AvgCpm = result.AvgCpm, + EstimatedCpa = result.EstimatedCpa, + Ctr = result.Ctr + }, + EfficiencyScore = Math.Round(scored[channel], 3), + StrengthLabel = GetStrengthLabel(channel, request.Objective), + Confidence = result.Confidence, + DataSource = result.DataSource + }); + } + + channelAllocations.Sort((a, b) => b.AllocationPercent.CompareTo(a.AllocationPercent)); + + sw.Stop(); + _logger.LogInformation("[GeneralEngine] Complete | Elapsed={Ms}ms", sw.ElapsedMilliseconds); + + return new SpendDistributionResponse + { + Ok = true, + Objective = request.Objective, + TotalBudget = request.MonthlyBudget, + Channels = channelAllocations, + Recommendation = BuildRecommendation(channelAllocations, request.Objective), + Metadata = new DistributionMeta + { + GeneratedAt = DateTimeOffset.UtcNow, + ForecastPeriod = "30 days", + Engine = EngineName + } + }; + } + + // ════════════════════════════════════════════════ + // Provider calls + // ════════════════════════════════════════════════ + + private async Task FetchGoogleAsync( + SpendDistributionRequest request, CancellationToken ct) + { + try + { + var providerUrl = request.ProviderUrls?.GetValueOrDefault("google_ads") ?? ""; + var key = request.InternalKeys?.GetValueOrDefault("google_ads") ?? ""; + + if (string.IsNullOrWhiteSpace(providerUrl)) + return EmulatedGoogle(request); + + var payload = new + { + keywords = request.Keywords, + geoTargetIds = request.GeoTargeting?.GeoTargetIds ?? new List(), + monthlyBudget = request.MonthlyBudget, + currencyCode = "USD", + forecastDays = 30 + }; + + var providerRequest = new + { + operation = "KeywordForecast", + requestId = Guid.NewGuid().ToString("N"), + payload + }; + + var httpClient = _http.CreateClient(); + using var msg = new HttpRequestMessage( + HttpMethod.Post, $"{providerUrl}/internal/execute"); + msg.Headers.Add("X-Internal-Key", key); + msg.Content = new StringContent( + JsonSerializer.Serialize(providerRequest, _jsonOpts), + System.Text.Encoding.UTF8, "application/json"); + + using var resp = await httpClient.SendAsync(msg, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("[GeneralEngine] Google provider {Status}", (int)resp.StatusCode); + return EmulatedGoogle(request); + } + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + var data = root.TryGetProperty("data", out var d) ? d : root; + var monthly = data.GetProperty("monthly"); + var metrics = data.GetProperty("metrics"); + + return new ProviderResult + { + Provider = "google_ads", + Impressions = monthly.TryGetProperty("impressions", out var imp) ? imp.GetDouble() : 0, + Clicks = monthly.TryGetProperty("clicks", out var cl) ? cl.GetDouble() : 0, + Conversions = monthly.TryGetProperty("conversions", out var conv) ? conv.GetDouble() : 0, + Reach = null, + AvgCpc = metrics.TryGetProperty("avgCpc", out var cpc) ? cpc.GetDecimal() : 0, + AvgCpm = metrics.TryGetProperty("avgCpm", out var cpm) ? cpm.GetDecimal() : 0, + Ctr = metrics.TryGetProperty("ctr", out var ctr) ? ctr.GetDouble() : 0, + EstimatedCpa = metrics.TryGetProperty("estimatedCpa", out var cpa) && cpa.ValueKind != JsonValueKind.Null + ? cpa.GetDecimal() : null, + Confidence = data.TryGetProperty("confidence", out var cf) ? cf.GetString() ?? "low" : "low", + DataSource = data.TryGetProperty("dataSource", out var ds) ? ds.GetString() ?? "emulated" : "emulated" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "[GeneralEngine] Google provider call failed"); + return EmulatedGoogle(request); + } + } + + private static async Task FetchMetaAsync( + SpendDistributionRequest request, CancellationToken ct) + { + // Phase 2: call MetaApi /internal/execute → DeliveryEstimate + await Task.CompletedTask; + + var budget = request.MonthlyBudget; + var rng = new Random((int)(budget * 77)); + var v = 0.85 + (rng.NextDouble() * 0.30); + + var cpm = 12.50m + (decimal)(rng.NextDouble() * 8.0); + var impressions = budget > 0 ? (double)(budget / cpm) * 1000 * v : 0; + var reach = impressions * 0.42; + var clickRate = 0.012 + (rng.NextDouble() * 0.008); + var clicks = impressions * clickRate; + var conversions = clicks * (0.025 + rng.NextDouble() * 0.015); + var avgCpc = clicks > 0 ? budget / (decimal)clicks : 0; + var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null; + + return new ProviderResult + { + Provider = "meta", + Impressions = Math.Round(impressions), + Clicks = Math.Round(clicks), + Conversions = Math.Round(conversions, 1), + Reach = Math.Round(reach), + AvgCpc = Math.Round(avgCpc, 2), + AvgCpm = Math.Round(cpm, 2), + Ctr = Math.Round(clickRate, 4), + EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null, + Confidence = "low", + DataSource = "emulated" + }; + } + + private static ProviderResult TemplateForecast(string provider) => + new() { Provider = provider, Confidence = "none", DataSource = "template" }; + + private ProviderResult EmulatedGoogle(SpendDistributionRequest request) + { + var budget = request.MonthlyBudget; + var kwCount = Math.Max(request.Keywords.Count, 1); + var rng = new Random((int)(budget * 100) + kwCount); + var v = 0.85 + (rng.NextDouble() * 0.30); + + var baseCpc = 2.50m - (decimal)(Math.Min(kwCount, 20) / 20.0 * 1.20); + var clicks = budget > 0 ? (double)(budget / baseCpc) * v : 0; + var impressions = clicks / 0.045; + var conversions = clicks * 0.035; + var ctr = impressions > 0 ? clicks / impressions : 0; + var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0; + var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null; + + return new ProviderResult + { + Provider = "google_ads", + Impressions = Math.Round(impressions), + Clicks = Math.Round(clicks), + Conversions = Math.Round(conversions, 1), + AvgCpc = Math.Round(baseCpc, 2), + AvgCpm = Math.Round((decimal)cpm, 2), + Ctr = Math.Round(ctr, 4), + EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null, + Confidence = "low", + DataSource = "emulated" + }; + } + + // ════════════════════════════════════════════════ + // Scoring + // ════════════════════════════════════════════════ + + private static Dictionary ScoreChannels( + Dictionary results, MetricWeights w) + { + var scoreable = results + .Where(r => r.Value.DataSource != "template") + .ToDictionary(r => r.Key, r => r.Value); + + if (scoreable.Count == 0) + return results.ToDictionary(r => r.Key, _ => 1.0); + + var maxImp = scoreable.Values.Max(r => r.Impressions); + var maxReach = scoreable.Values.Max(r => r.Reach ?? 0); + var maxClicks = scoreable.Values.Max(r => r.Clicks); + var maxConv = scoreable.Values.Max(r => r.Conversions); + var maxCtr = scoreable.Values.Max(r => r.Ctr); + var minCpm = scoreable.Values.Where(r => r.AvgCpm > 0).Select(r => r.AvgCpm).DefaultIfEmpty(1).Min(); + var minCpc = scoreable.Values.Where(r => r.AvgCpc > 0).Select(r => r.AvgCpc).DefaultIfEmpty(1).Min(); + var minCpa = scoreable.Values.Where(r => r.EstimatedCpa > 0) + .Select(r => r.EstimatedCpa!.Value).DefaultIfEmpty(1).Min(); + + var scores = new Dictionary(); + + foreach (var (channel, r) in scoreable) + { + double score = 0; + score += w.Impressions * SafeDiv(r.Impressions, maxImp); + score += w.Reach * SafeDiv(r.Reach ?? 0, maxReach > 0 ? maxReach : 1); + score += w.Clicks * SafeDiv(r.Clicks, maxClicks); + score += w.Conversions * SafeDiv(r.Conversions, maxConv); + score += w.Ctr * SafeDiv(r.Ctr, maxCtr); + score += w.Cpm * (r.AvgCpm > 0 ? (double)(minCpm / r.AvgCpm) : 0); + score += w.Cpc * (r.AvgCpc > 0 ? (double)(minCpc / r.AvgCpc) : 0); + score += w.Cpa * (r.EstimatedCpa > 0 ? (double)(minCpa / r.EstimatedCpa!.Value) : 0); + scores[channel] = score; + } + + var avg = scores.Values.Average(); + foreach (var channel in results.Keys.Except(scoreable.Keys)) + scores[channel] = avg * 0.5; + + return scores; + } + + private static Dictionary DeriveAllocations(Dictionary scores) + { + var total = scores.Values.Sum(); + if (total == 0) + { + var even = 100 / scores.Count; + return scores.ToDictionary(s => s.Key, _ => even); + } + + var raw = scores.ToDictionary( + s => s.Key, + s => Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, + (int)Math.Round(s.Value / total * 100)))); + + var sum = raw.Values.Sum(); + if (sum != 100 && raw.Count > 0) + { + var diff = 100 - sum; + var top = raw.OrderByDescending(r => r.Value).First().Key; + raw[top] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[top] + diff)); + } + + return raw; + } + + // ════════════════════════════════════════════════ + // Copy helpers (keep identical to Gateway originals) + // ════════════════════════════════════════════════ + + private static double SafeDiv(double n, double d) => d > 0 ? n / d : 0; + + private static string GetStrengthLabel(string channel, string objective) => channel switch + { + "google_ads" => objective switch + { + "awareness" => "Strong for search visibility", + "traffic" => "Strong for search intent clicks", + "leads" => "Strong for high-intent leads", + "sales" => "Strong for purchase intent", + _ => "Search & intent targeting" + }, + "meta" => objective switch + { + "awareness" => "Strong for reach & discovery", + "traffic" => "Strong for social traffic", + "leads" => "Strong for lead gen forms", + "sales" => "Strong for retargeting & social proof", + _ => "Social reach & engagement" + }, + "tiktok" => "Video-first engagement", + _ => "Advertising channel" + }; + + private static DistributionRecommendation BuildRecommendation( + List channels, string objective) + { + if (channels.Count < 2) + return new DistributionRecommendation + { + Summary = $"Budget allocated to {channels.FirstOrDefault()?.Provider ?? "channel"} for {objective}.", + Highlights = new List() + }; + + var top = channels[0]; + var second = channels[1]; + var highlights = new List(); + + if (top.Estimates.Clicks > 0 && second.Estimates.Clicks > 0) + { + var ratio = top.Estimates.Clicks / second.Estimates.Clicks; + if (ratio > 1.3) + highlights.Add($"{DisplayName(top.Provider)}: ~{ratio:F0}x more clicks per dollar"); + } + + if (top.Estimates.Impressions > 0 && second.Estimates.Impressions > 0) + { + var ratio = second.Estimates.Impressions / top.Estimates.Impressions; + if (ratio > 1.5) + highlights.Add($"{DisplayName(second.Provider)}: ~{ratio:F0}x more impressions per dollar"); + } + + if (top.Estimates.EstimatedCpa > 0 && second.Estimates.EstimatedCpa > 0) + { + highlights.Add( + $"CPA range: ${Math.Min(top.Estimates.EstimatedCpa!.Value, second.Estimates.EstimatedCpa!.Value):F0}" + + $"–${Math.Max(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0} across channels"); + } + + return new DistributionRecommendation + { + Summary = $"Recommended {top.AllocationPercent}/{second.AllocationPercent} split " + + $"between {DisplayName(top.Provider)} and {DisplayName(second.Provider)}, " + + $"optimized for {objective}.", + Highlights = highlights + }; + } + + private static string DisplayName(string p) => p switch + { + "google_ads" => "Google", + "meta" => "Meta", + "tiktok" => "TikTok", + _ => p + }; + + // ── Internal result from a single provider ── + private sealed class ProviderResult + { + public string Provider { get; set; } = string.Empty; + public double Impressions { get; set; } + public double? Reach { get; set; } + public double Clicks { get; set; } + public double Conversions { get; set; } + public decimal AvgCpc { get; set; } + public decimal AvgCpm { get; set; } + public double Ctr { get; set; } + public decimal? EstimatedCpa { get; set; } + public string Confidence { get; set; } = "none"; + public string DataSource { get; set; } = "none"; + } +} + +// ════════════════════════════════════════════════ +// Objective-weighted scoring (copied from Gateway ForecastModels) +// ════════════════════════════════════════════════ + +public sealed class MetricWeights +{ + public double Reach { get; } + public double Impressions { get; } + public double Cpm { get; } + public double Clicks { get; } + public double Cpc { get; } + public double Ctr { get; } + public double Conversions { get; } + public double Cpa { get; } + + public MetricWeights(double reach, double impressions, double cpm, + double clicks, double cpc, double ctr, double conversions, double cpa) + { + Reach = reach; Impressions = impressions; Cpm = cpm; + Clicks = clicks; Cpc = cpc; Ctr = ctr; + Conversions = conversions; Cpa = cpa; + } +} + +public static class ObjectiveWeights +{ + private static readonly Dictionary _weights = + new(StringComparer.OrdinalIgnoreCase) + { + // reach imp cpm clicks cpc ctr conv cpa + ["awareness"] = new MetricWeights(0.35, 0.25, 0.20, 0.05, 0.05, 0.05, 0.00, 0.00), + ["traffic"] = new MetricWeights(0.05, 0.10, 0.10, 0.30, 0.30, 0.15, 0.00, 0.00), + ["leads"] = new MetricWeights(0.05, 0.05, 0.05, 0.15, 0.15, 0.10, 0.25, 0.20), + ["sales"] = new MetricWeights(0.05, 0.05, 0.05, 0.10, 0.10, 0.10, 0.30, 0.25), + }; + + private static readonly MetricWeights _default = + new(0.10, 0.10, 0.10, 0.20, 0.20, 0.10, 0.10, 0.10); + + public static MetricWeights For(string objective) => + _weights.TryGetValue(objective, out var w) ? w : _default; +} diff --git a/IntelligenceApi/Engines/ISpendDistributionEngine.cs b/IntelligenceApi/Engines/ISpendDistributionEngine.cs new file mode 100644 index 0000000..04b31bd --- /dev/null +++ b/IntelligenceApi/Engines/ISpendDistributionEngine.cs @@ -0,0 +1,35 @@ +using IntelligenceApi.Models; + +namespace IntelligenceApi.Engines; + +/// +/// Contract for all spend distribution engines. +/// +/// Each engine encapsulates the logic for recommending how a client should +/// distribute their ad budget across providers. Engines vary by client category: +/// +/// General — rules-based scoring (default, free tier) +/// Franchisee — location-aware, franchise-specific signals (premium) +/// Franchisor — network-wide co-op budget management (premium) +/// FoodFranchisee — geo density, competitor proximity, demographics (AI-driven) +/// +/// The contract is intentionally narrow: one method in, one model out. +/// Each engine can call external APIs, ML models, or run local logic — +/// the caller doesn't need to know which. +/// +public interface ISpendDistributionEngine +{ + /// + /// Human-readable name for logging, metadata, and billing attribution. + /// e.g. "General", "Franchisee", "FoodFranchisee" + /// + string EngineName { get; } + + /// + /// Generate a spend distribution recommendation for the given request. + /// Must never throw — return a valid response with reduced confidence on errors. + /// + Task RecommendAsync( + SpendDistributionRequest request, + CancellationToken ct); +} diff --git a/IntelligenceApi/IntelligenceAPI.http b/IntelligenceApi/IntelligenceAPI.http new file mode 100644 index 0000000..613f13b --- /dev/null +++ b/IntelligenceApi/IntelligenceAPI.http @@ -0,0 +1,6 @@ +@IntelligenceAPI_HostAddress = http://localhost:5271 + +GET {{IntelligenceAPI_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/IntelligenceApi/IntelligenceApi.csproj b/IntelligenceApi/IntelligenceApi.csproj new file mode 100644 index 0000000..0108208 --- /dev/null +++ b/IntelligenceApi/IntelligenceApi.csproj @@ -0,0 +1,10 @@ + + + + net8.0 + enable + enable + IntelligenceApi + + + diff --git a/IntelligenceApi/Models/DemographicsModels.cs b/IntelligenceApi/Models/DemographicsModels.cs new file mode 100644 index 0000000..1530eec --- /dev/null +++ b/IntelligenceApi/Models/DemographicsModels.cs @@ -0,0 +1,75 @@ +namespace IntelligenceApi.Models; + +// ════════════════════════════════════════════════ +// Request: Gateway → IntelligenceApi +// Gateway sends raw census data fetched from DB, +// plus the ZCTA for context. +// ════════════════════════════════════════════════ + +public sealed class DemographicAnalysisRequest +{ + /// 5-digit ZIP code / ZCTA + public string Zcta { get; set; } = string.Empty; + + /// + /// Raw census row from spDemographics. + /// All numeric fields; nulls treated as zero. + /// + public CensusData Census { get; set; } = new(); +} + +public sealed class CensusData +{ + public int TotalPopulation { get; set; } + public int TotalHouseholds { get; set; } + public int MedianIncome { get; set; } + public int MedianHomeValue { get; set; } + public decimal Pct18to24 { get; set; } + public decimal Pct25to34 { get; set; } + public decimal Pct35to44 { get; set; } + public decimal Pct45to54 { get; set; } + public decimal Pct55to64 { get; set; } + public decimal Pct65plus { get; set; } + public decimal PctBachelorPlus { get; set; } + public decimal PctOwnerOccupied { get; set; } + public decimal PctRenterOccupied { get; set; } + public decimal PctFamilyHouseholds { get; set; } + public decimal PctLivingAlone { get; set; } + public decimal PctHispanic { get; set; } + public decimal PctAsian { get; set; } + public decimal PctBlack { get; set; } + public decimal PctRemoteWork { get; set; } + public decimal PctPublicTransit { get; set; } + public decimal UnemploymentRate { get; set; } + public decimal PctIncomeUnder30k { get; set; } + public decimal PctIncome30kTo75k { get; set; } + public decimal PctIncome75kTo150k { get; set; } + public decimal PctIncome150kPlus { get; set; } +} + +// ════════════════════════════════════════════════ +// Response: IntelligenceApi → Gateway → Client +// ════════════════════════════════════════════════ + +public sealed class DemographicAnalysisResponse +{ + public bool Ok { get; set; } = true; + public string Zcta { get; set; } = string.Empty; + + /// Raw census metrics passed through for display + public CensusData Census { get; set; } = new(); + + /// Derived recommendations for wizard chip auto-population + public AudienceRecommendations Recommendations { get; set; } = new(); + + /// Human-readable summary strings for the insight bar + public List Insights { get; set; } = new(); +} + +public sealed class AudienceRecommendations +{ + public List AgeRanges { get; set; } = new(); + public List Incomes { get; set; } = new(); + public string AgeSkew { get; set; } = "balanced"; + public string MarketScope { get; set; } = "local"; +} diff --git a/IntelligenceApi/Models/SpendDistributionModels.cs b/IntelligenceApi/Models/SpendDistributionModels.cs new file mode 100644 index 0000000..dfd288d --- /dev/null +++ b/IntelligenceApi/Models/SpendDistributionModels.cs @@ -0,0 +1,117 @@ +namespace IntelligenceApi.Models; + +// ════════════════════════════════════════════════ +// Request: Gateway → IntelligenceApi +// Gateway injects clientCategory from ClientContext +// before forwarding the wizard's forecast request. +// ════════════════════════════════════════════════ + +public sealed class SpendDistributionRequest +{ + /// + /// Client category — the primary engine selector. + /// Values: General | Franchisee | Franchisor | FoodFranchisee | etc. + /// Injected by the Gateway from ClientContext.ClientCategory. + /// + public string ClientCategory { get; set; } = "General"; + + /// Advertising objective: awareness, traffic, leads, sales + public string Objective { get; set; } = "traffic"; + + /// Business category from wizard Step 1 (industry) + public string? BusinessCategory { get; set; } + + /// Keywords from URL analysis + public List Keywords { get; set; } = new(); + + /// Geo targeting from audience step + public GeoTargeting? GeoTargeting { get; set; } + + /// Audience parameters + public AudienceParams? Audience { get; set; } + + /// Monthly budget in whole dollars + public decimal MonthlyBudget { get; set; } + + /// Channels to estimate + public List? Channels { get; set; } + + /// + /// Provider base URLs forwarded from Gateway config. + /// Allows engines to call providers without needing their own config. + /// + public Dictionary? ProviderUrls { get; set; } + + /// Internal API keys forwarded from Gateway config. + public Dictionary? InternalKeys { get; set; } +} + +public sealed class GeoTargeting +{ + public List? ZipCodes { get; set; } + public double? RadiusMiles { get; set; } + public List? GeoTargetIds { get; set; } +} + +public sealed class AudienceParams +{ + public int? AgeMin { get; set; } + public int? AgeMax { get; set; } + public List? Genders { get; set; } + public List? Interests { get; set; } +} + +// ════════════════════════════════════════════════ +// Response: IntelligenceApi → Gateway → Client +// Identical shape to ChannelForecastResponse in +// the Gateway so no client changes are needed. +// ════════════════════════════════════════════════ + +public sealed class SpendDistributionResponse +{ + public bool Ok { get; set; } = true; + public string Objective { get; set; } = string.Empty; + public decimal TotalBudget { get; set; } + public List Channels { get; set; } = new(); + public DistributionRecommendation? Recommendation { get; set; } + public DistributionMeta Metadata { get; set; } = new(); +} + +public sealed class ChannelAllocation +{ + public string Provider { get; set; } = string.Empty; + public int AllocationPercent { get; set; } + public decimal AllocatedBudget { get; set; } + public AllocationMetrics Estimates { get; set; } = new(); + public double EfficiencyScore { get; set; } + public string StrengthLabel { get; set; } = string.Empty; + public string Confidence { get; set; } = "none"; + public string DataSource { get; set; } = "none"; +} + +public sealed class AllocationMetrics +{ + public double Impressions { get; set; } + public double? Reach { get; set; } + public double Clicks { get; set; } + public double Conversions { get; set; } + public decimal AvgCpc { get; set; } + public decimal AvgCpm { get; set; } + public decimal? EstimatedCpa { get; set; } + public double Ctr { get; set; } +} + +public sealed class DistributionRecommendation +{ + public string Summary { get; set; } = string.Empty; + public List Highlights { get; set; } = new(); +} + +public sealed class DistributionMeta +{ + public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow; + public string ForecastPeriod { get; set; } = "30 days"; + + /// Which engine handled this request — useful for debugging and billing. + public string Engine { get; set; } = "General"; +} diff --git a/IntelligenceApi/Program.cs b/IntelligenceApi/Program.cs new file mode 100644 index 0000000..b7270ed --- /dev/null +++ b/IntelligenceApi/Program.cs @@ -0,0 +1,105 @@ +using IntelligenceApi.Engines; +using IntelligenceApi.Engines.Franchisee; +using IntelligenceApi.Engines.Franchisor; +using IntelligenceApi.Engines.General; + +var builder = WebApplication.CreateBuilder(args); + +// -------------------- +// Container-friendly HTTP binding +// -------------------- +var port = Environment.GetEnvironmentVariable("PORT") ?? "8081"; +builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); + +// -------------------- +// Services +// -------------------- +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddHttpClient(); + +// ── Engines ── +// General is a singleton — stateless, no DB dependency, safe to share. +// Franchisee and Franchisor delegate to General; register as singletons too. +// When real data services are added, switch to Scoped. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// -------------------- +// Security: internal-only access +// Simple shared key check — requests must include X-Internal-Key header +// matching the INTELLIGENCE_INTERNAL_KEY environment variable. +// The Gateway sets this key; the container is not publicly exposed. +// -------------------- +var internalKey = builder.Configuration["INTELLIGENCE_INTERNAL_KEY"] + ?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY"); + +var app = builder.Build(); + +// Health check (no auth — used by ACA liveness probe) +app.MapGet("/health", () => Results.Ok(new +{ + ok = true, + service = "IntelligenceApi", + timestamp = DateTimeOffset.UtcNow +})); + +app.MapGet("/", () => Results.Ok(new +{ + service = "IntelligenceApi", + version = "1.0.0", + status = "Spend distribution engine running" +})); + +// Internal key middleware — validates all /api/* requests +app.Use(async (context, next) => +{ + var path = context.Request.Path.Value ?? ""; + + // Pass health and root through unauthenticated + if (path == "/" || path.StartsWith("/health", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) + { + await next(); + return; + } + + // In development, skip key check if not configured + if (string.IsNullOrWhiteSpace(internalKey)) + { + if (app.Environment.IsDevelopment()) + { + await next(); + return; + } + + context.Response.StatusCode = 503; + await context.Response.WriteAsJsonAsync(new + { + ok = false, + error = "Service not configured (missing INTELLIGENCE_INTERNAL_KEY)" + }); + return; + } + + if (!context.Request.Headers.TryGetValue("X-Internal-Key", out var key) + || key.FirstOrDefault() != internalKey) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new { ok = false, error = "Unauthorized" }); + return; + } + + await next(); +}); + +app.UseAuthorization(); +app.MapControllers(); + +Console.WriteLine($"[IntelligenceApi] Starting on port {port}"); +Console.WriteLine($"[IntelligenceApi] Internal key configured: {!string.IsNullOrWhiteSpace(internalKey)}"); + +app.Run(); diff --git a/IntelligenceApi/Properties/launchSettings.json b/IntelligenceApi/Properties/launchSettings.json new file mode 100644 index 0000000..58b61ab --- /dev/null +++ b/IntelligenceApi/Properties/launchSettings.json @@ -0,0 +1,52 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5271" + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7044;http://localhost:5271" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Container (.NET SDK)": { + "commandName": "SdkContainer", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:50991", + "sslPort": 44357 + } + } +} \ No newline at end of file diff --git a/IntelligenceApi/appsettings.Development.json b/IntelligenceApi/appsettings.Development.json new file mode 100644 index 0000000..5ce9be4 --- /dev/null +++ b/IntelligenceApi/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "IntelligenceApi": "Debug" + } + } +} diff --git a/IntelligenceApi/appsettings.json b/IntelligenceApi/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/IntelligenceApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Management/Controllers/Admin/AdminCampaignsController.cs b/Management/Controllers/Admin/AdminCampaignsController.cs new file mode 100644 index 0000000..cfe0e58 --- /dev/null +++ b/Management/Controllers/Admin/AdminCampaignsController.cs @@ -0,0 +1,38 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Admin endpoints for campaign (initiative) management. +/// Lists initiatives across all clients with their channel campaign details. +/// Requires Admin role. +/// +/// ENDPOINTS: +/// GET /api/admin/campaigns - List all initiatives with channels +/// GET /api/admin/campaigns/{id} - Get initiative detail with channels +/// +[ApiController] +[Route("api/admin/campaigns")] +public sealed class AdminCampaignsController : AdminControllerBase +{ + public AdminCampaignsController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + /// + /// List all initiatives across all clients, with nested channel campaigns. + /// Optional filters: status, clientId, dateFrom, dateTo. + /// + [HttpPost("list")] + public Task List([FromBody] JsonElement body, CancellationToken ct) + => CallProc("spAdminCampaigns", "list", body.ToString(), ct); + + /// + /// Get initiative by ID with full channel campaign details. + /// + [HttpGet("{initiativeId:long}")] + public Task Get(long initiativeId, CancellationToken ct) + => CallProc("spAdminCampaigns", "get", new { initiativeId }, ct); +} diff --git a/Management/Controllers/Admin/AdminClientActivityController.cs b/Management/Controllers/Admin/AdminClientActivityController.cs new file mode 100644 index 0000000..acacfdb --- /dev/null +++ b/Management/Controllers/Admin/AdminClientActivityController.cs @@ -0,0 +1,31 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Client activity log — queries tbAccessLog (populated by Gateway's AccessLogMiddleware). +/// +/// ENDPOINTS: +/// POST /api/admin/client-activity/list - Paginated activity for a specific client +/// POST /api/admin/client-activity/summary - Request counts + last-seen per client +/// +[ApiController] +[Route("api/admin/client-activity")] +public sealed class AdminClientActivityController : AdminControllerBase +{ + private const string Proc = "spClientActivity"; + + public AdminClientActivityController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + [HttpPost("list")] + public Task List([FromBody] JsonElement body, CancellationToken ct) + => CallProc(Proc, "list", body.ToString(), ct); + + [HttpPost("summary")] + public Task Summary([FromBody] JsonElement body, CancellationToken ct) + => CallProc(Proc, "summary", body.ToString(), ct); +} diff --git a/Management/Controllers/Admin/AdminClientDocumentsController.cs b/Management/Controllers/Admin/AdminClientDocumentsController.cs new file mode 100644 index 0000000..46a56dc --- /dev/null +++ b/Management/Controllers/Admin/AdminClientDocumentsController.cs @@ -0,0 +1,188 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.SqlClient; +using System.Data; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Client document management (admin view). +/// Admins can list, upload, download, and delete documents for any client. +/// All documents created here have scope='client' and a required clientId. +/// +/// POST /api/admin/client-documents/list - List docs for a client +/// POST /api/admin/client-documents/upload - Upload doc for a client +/// GET /api/admin/client-documents/{id}/download +/// DELETE /api/admin/client-documents/{id} +/// +[ApiController] +[Route("api/admin/client-documents")] +public sealed class AdminClientDocumentsController : AdminControllerBase +{ + private readonly IConfiguration _config; + + public AdminClientDocumentsController(SqlService sql, ClientContext client, IConfiguration config, ILogger log) + : base(sql, client, log) + { + _config = config; + } + + // ── POST /api/admin/client-documents/list ──────────────────────────────── + [HttpPost("list")] + public async Task List([FromBody] JsonElement body, CancellationToken ct) + { + try + { + var clientId = body.TryGetProperty("clientId", out var cid) ? cid.GetString() : null; + if (string.IsNullOrWhiteSpace(clientId)) + return BadRequest(new { ok = false, error = "clientId is required" }); + + var rqst = JsonSerializer.Serialize(new { scope = "client", clientId }); + var result = await Sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct); + return Content(result, "application/json"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Client document list failed"); + return StatusCode(500, new { ok = false, message = ex.Message }); + } + } + + // ── POST /api/admin/client-documents/upload ────────────────────────────── + [HttpPost("upload")] + [RequestSizeLimit(52_428_800)] + public async Task Upload( + IFormFile file, + [FromForm] string clientId, + [FromForm] string category, + [FromForm] string? description = null, + CancellationToken ct = default) + { + if (file == null || file.Length == 0) + return BadRequest(new { ok = false, message = "No file provided" }); + + if (string.IsNullOrWhiteSpace(clientId)) + return BadRequest(new { ok = false, message = "clientId is required" }); + + try + { + byte[] fileBytes; + using (var ms = new MemoryStream()) + { + await file.CopyToAsync(ms, ct); + fileBytes = ms.ToArray(); + } + + var rqst = JsonSerializer.Serialize(new + { + docFileName = file.FileName, + docMimeType = file.ContentType, + docFileSize = file.Length, + docCategory = category, + docDescription = description, + docUploadedBy = Client.Email, + docScope = "client", + docCltId = clientId + }); + + Logger.LogInformation("[ClientDocs] Upload {FileName} for client {ClientId} by {User}", + file.FileName, clientId, Client.Email); + + var result = await ExecUploadAsync(rqst, fileBytes, ct); + return Content(result, "application/json"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Client document upload failed: {FileName}", file?.FileName); + return StatusCode(500, new { ok = false, message = ex.Message }); + } + } + + // ── GET /api/admin/client-documents/{id}/download ──────────────────────── + [HttpGet("{id:long}/download")] + public async Task Download(long id, CancellationToken ct = default) + { + try + { + var cs = _config.GetConnectionString("Sql") + ?? throw new InvalidOperationException("Missing ConnectionStrings:Sql"); + + await using var conn = new SqlConnection(cs); + await conn.OpenAsync(ct); + + await using var cmd = new SqlCommand("dbo.usp_Document", conn) + { + CommandType = CommandType.StoredProcedure, + CommandTimeout = 60 + }; + cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" }); + cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = JsonSerializer.Serialize(new { docId = id }) }); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) + return NotFound(new { ok = false, message = "Document not found" }); + + var fileName = reader.GetString(reader.GetOrdinal("docFileName")); + var mimeType = reader.GetString(reader.GetOrdinal("docMimeType")); + var content = (byte[])reader["docContent"]; + + return File(content, mimeType, fileName); + } + catch (Exception ex) + { + Logger.LogError(ex, "Client document download failed: docId={DocId}", id); + return StatusCode(500, new { ok = false, message = ex.Message }); + } + } + + // ── DELETE /api/admin/client-documents/{id} ────────────────────────────── + [HttpDelete("{id:long}")] + public async Task Delete(long id, CancellationToken ct = default) + { + try + { + Logger.LogInformation("[ClientDocs] Delete docId={DocId} by {User}", id, Client.Email); + var rqst = JsonSerializer.Serialize(new { docId = id }); + var result = await Sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct); + return Content(result, "application/json"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Client document delete failed: docId={DocId}", id); + return StatusCode(500, new { ok = false, message = ex.Message }); + } + } + + // ─── Upload helper ──────────────────────────────────────────────────────── + private async Task ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct) + { + var cs = _config.GetConnectionString("Sql") + ?? throw new InvalidOperationException("Missing ConnectionStrings:Sql"); + + await using var conn = new SqlConnection(cs); + await conn.OpenAsync(ct); + + await using var cmd = new SqlCommand("dbo.usp_Document", conn) + { + CommandType = CommandType.StoredProcedure, + CommandTimeout = 60 + }; + + cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" }); + cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst }); + cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent }); + + var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1) + { + Direction = ParameterDirection.Output + }; + cmd.Parameters.Add(pResp); + + await cmd.ExecuteNonQueryAsync(ct); + + return pResp.Value as string + ?? JsonSerializer.Serialize(new { ok = false, message = "No response from database" }); + } +} diff --git a/Management/Controllers/Admin/AdminClientUsersController.cs b/Management/Controllers/Admin/AdminClientUsersController.cs new file mode 100644 index 0000000..0afab96 --- /dev/null +++ b/Management/Controllers/Admin/AdminClientUsersController.cs @@ -0,0 +1,84 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Client-planet user management — tbClientUser / tbClientUserRole. +/// No admin-plane concepts here whatsoever. +/// +/// ENDPOINTS: +/// GET /api/admin/client-users - List client users +/// GET /api/admin/client-users/{userId} - Get single user +/// PUT /api/admin/client-users/{userId} - Update display name / status +/// DELETE /api/admin/client-users/{userId} - Deactivate +/// POST /api/admin/client-users/{userId}/link - Link to a client +/// DELETE /api/admin/client-users/{userId}/link/{clientId} - Unlink from a client +/// +[ApiController] +[Route("api/admin/client-users")] +public sealed class AdminClientUsersController : AdminControllerBase +{ + private const string Proc = "spClientUsers"; + + public AdminClientUsersController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + [HttpPost("list")] + public Task List([FromBody] JsonElement body, CancellationToken ct) + => CallProc(Proc, "list", body.ToString(), ct); + + [HttpGet("{userId}")] + public Task Get(string userId, CancellationToken ct) + => CallProc(Proc, "get", new { userId }, ct); + + [HttpPut("{userId}")] + public Task Update(string userId, [FromBody] JsonElement body, CancellationToken ct) + { + Logger.LogInformation("[ClientUsers] Update {Id} by {User}", userId, Client.Email); + return CallProc(Proc, "update", new + { + userId, + displayName = Str(body, "displayName"), + status = Str(body, "status") + }, ct); + } + + [HttpDelete("{userId}")] + public Task Deactivate(string userId, CancellationToken ct) + { + Logger.LogWarning("[ClientUsers] Deactivate {Id} by {User}", userId, Client.Email); + return CallProc(Proc, "deactivate", new { userId }, ct); + } + + [HttpPost("{userId}/link")] + public Task LinkToClient(string userId, [FromBody] JsonElement body, CancellationToken ct) + { + var clientId = Str(body, "clientId"); + if (string.IsNullOrWhiteSpace(clientId)) + return Task.FromResult(ValidationError("clientId is required")); + + Logger.LogInformation("[ClientUsers] Link user {UserId} → client {ClientId} by {User}", + userId, clientId, Client.Email); + + return CallProc(Proc, "linkToClient", new + { + userId, + clientId, + role = Str(body, "role") ?? "User" + }, ct); + } + + [HttpDelete("{userId}/link/{clientId}")] + public Task UnlinkFromClient(string userId, string clientId, CancellationToken ct) + { + Logger.LogInformation("[ClientUsers] Unlink user {UserId} from client {ClientId} by {User}", + userId, clientId, Client.Email); + return CallProc(Proc, "unlinkFromClient", new { userId, clientId }, ct); + } + + private static string? Str(JsonElement el, string key) => + el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; +} diff --git a/Management/Controllers/Admin/AdminClientsController.cs b/Management/Controllers/Admin/AdminClientsController.cs index 96e6cd3..8cf3530 100644 --- a/Management/Controllers/Admin/AdminClientsController.cs +++ b/Management/Controllers/Admin/AdminClientsController.cs @@ -1,92 +1,341 @@ using Management.Data; using Management.Security; +using Management.Services; using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Text.Json; namespace Management.Controllers.Admin; /// -/// Admin endpoints for client (organization) management. -/// Requires Admin role. -/// +/// Admin endpoints for client lifecycle management. +/// Auth enforced at middleware level (/api/admin/* → Session + Admin role). +/// All operations pass JSON to stored procs (@action/@rqst/@resp). +/// +/// APPROVAL FLOW (POST /api/admin/clients): +/// 1. Fetch full applicant from Registration Function — OID comes from server, never browser +/// 2. spClientManagement 'create' → tbClient + tbClientUser + tbClientUserRole (atomic) +/// 3. POST JSON to each provider container → sub-account creation +/// 4. spClientManagement 'recordAdAccount' per provider result +/// 5. spNotification 'queue' → welcome + provisioning alert queued for async send +/// 6. Registration 'complete' → mark registration closed +/// /// ENDPOINTS: -/// GET /api/admin/clients - List clients -/// GET /api/admin/clients/{id} - Get client -/// POST /api/admin/clients - Create client -/// PUT /api/admin/clients/{id} - Update client -/// DELETE /api/admin/clients/{id} - Deactivate client +/// GET /api/admin/clients - List clients +/// GET /api/admin/clients/{clientId} - Get client detail +/// POST /api/admin/clients - Approve from registration +/// PUT /api/admin/clients/{clientId} - Update profile +/// POST /api/admin/clients/{clientId}/suspend - Suspend +/// POST /api/admin/clients/{clientId}/cancel - Cancel +/// POST /api/admin/clients/{clientId}/reactivate - Reactivate +/// GET /api/admin/clients/{clientId}/defaults - Wizard pre-fill +/// +/// REGISTRATION PROXY: +/// GET /api/registration/pending - List pending applicants +/// GET /api/registration/{id} - Get single applicant +/// POST /api/registration/{id}/reject - Reject applicant /// [ApiController] [Route("api/admin/clients")] public sealed class AdminClientsController : AdminControllerBase { - public AdminClientsController(SqlService sql, ClientContext client, ILogger log) - : base(sql, client, log) { } + private readonly RegistrationClient _registration; + private readonly IHttpClientFactory _http; + private readonly IConfiguration _cfg; - /// - /// List all clients with optional filtering. - /// - [HttpGet] - public Task List( - [FromQuery] string? status, - [FromQuery] int page = 1, - [FromQuery] int pageSize = 50, - CancellationToken ct = default) - => CallProc("spAdminClients", "list", new { status, page, pageSize }, ct); - - /// - /// Get client by ID. - /// - [HttpGet("{clientId}")] - public Task Get(string clientId, CancellationToken ct) - => CallProc("spAdminClients", "get", new { clientId }, ct); - - /// - /// Create a new client. - /// - [HttpPost] - public Task Create([FromBody] CreateClientRequest request, CancellationToken ct) + private static readonly JsonSerializerOptions JsonOpts = new() { - if (string.IsNullOrWhiteSpace(request?.ClientName)) - return Task.FromResult(ValidationError("clientName is required")); + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; - Logger.LogWarning("[Admin] CreateClient | Name={Name} | By={User}", request.ClientName, Client.Email); - return CallProc("spAdminClients", "create", new { clientName = request.ClientName.Trim() }, ct); + public AdminClientsController( + SqlService sql, + ClientContext client, + RegistrationClient registration, + IHttpClientFactory http, + IConfiguration cfg, + ILogger log) + : base(sql, client, log) + { + _registration = registration; + _http = http; + _cfg = cfg; } + private const string Proc = "spClientManagement"; + + // ── CRUD + Lifecycle ────────────────────────────────────────────────── + + [HttpPost("list")] + public Task List([FromBody] JsonElement body, CancellationToken ct) + => CallProc(Proc, "list", body.ToString(), ct); + + [HttpGet("{clientId}")] + public Task Get(string clientId, CancellationToken ct) + => CallProc(Proc, "get", new { clientId }, ct); + /// - /// Update client. + /// Approve a registration. Only registrationId is needed from the browser — + /// all data including the CIAM OID is fetched from the Registration Function server-side. /// - [HttpPut("{clientId}")] - public Task Update(string clientId, [FromBody] UpdateClientRequest request, CancellationToken ct) + [HttpPost] + public async Task Approve([FromBody] JsonElement body, CancellationToken ct) { - Logger.LogWarning("[Admin] UpdateClient | Id={Id} | By={User}", clientId, Client.Email); - return CallProc("spAdminClients", "update", new + var registrationId = Str(body, "registrationId"); + if (string.IsNullOrWhiteSpace(registrationId)) + return ValidationError("registrationId is required"); + + // ── 1. Fetch full applicant — OID comes from server record, not browser ── + var regDoc = await _registration.GetByIdAsync(registrationId, ct); + if (regDoc == null) + return StatusCode(502, new { ok = false, error = "Registration service unavailable" }); + + if (!regDoc.RootElement.TryGetProperty("applicant", out var app)) + return NotFound(new { ok = false, error = "Registration not found" }); + + var entraSub = Str(app, "entraSubjectId"); + var regEmail = Str(app, "contactEmail"); + var regName = Str(app, "contactName"); + var bizName = Str(app, "businessName"); + var websiteUrl = Str(app, "websiteUrl"); + var bizCategory = Str(app, "businessCategory"); + var cltCategory = Str(app, "clientCategory") ?? "General"; + var contactPhone= Str(app, "contactPhone"); + + if (string.IsNullOrWhiteSpace(entraSub)) + return BadRequest(new { ok = false, error = "Applicant has no verified identity — they must sign in through the registration portal before approval." }); + + if (string.IsNullOrWhiteSpace(bizName)) + return ValidationError("Registration has no business name"); + + Logger.LogInformation("[Approve] '{Name}' regId={RegId} OID={OID} by {User}", + bizName, registrationId, entraSub, Client.Email); + + // ── 2. Create tbClient + tbClientUser + tbClientUserRole (atomic in proc) ── + var createResp = await Sql.ExecProcAsync("dbo.spClientManagement", "create", + Json(new + { + registrantEntraSub = entraSub, + registrantEmail = regEmail, + registrantName = regName, + name = bizName.Trim(), + websiteUrl, + businessCategory = bizCategory, + contactName = regName, + contactEmail = regEmail, + contactPhone, + clientCategory = cltCategory, + approvedByEmail = Client.Email, + registrationRef = registrationId + }), ct: ct); + + using var createDoc = JsonDocument.Parse(createResp); + var cr = createDoc.RootElement; + + if (!Bol(cr, "ok")) + return BadRequest(new { ok = false, error = Str(cr, "error") ?? "DB create failed" }); + + var clientId = Str(cr, "clientId")!; + var wasCreated = Bol(cr, "created"); + + // ── 3 + 4. Provider sub-accounts ───────────────────────────────── + var providerResults = new List(); + + if (wasCreated) + { + foreach (var provider in new[] { "google", "meta", "tiktok" }) + { + var url = _cfg[$"{provider.ToUpper()}_PROVIDER_URL"]?.TrimEnd('/'); + var intKey = _cfg[$"{provider.ToUpper()}_INTERNAL_KEY"]; + + if (string.IsNullOrWhiteSpace(url)) + { + providerResults.Add(new { provider, status = "Skipped", error = "Not configured" }); + continue; + } + + try + { + using var http = _http.CreateClient(); + http.Timeout = TimeSpan.FromSeconds(30); + if (!string.IsNullOrWhiteSpace(intKey)) + http.DefaultRequestHeaders.Add("X-Internal-Key", intKey); + + var provResp = await http.PostAsync( + $"{url}/api/accounts/create", + new StringContent( + Json(new { clientId, clientName = bizName, contactEmail = regEmail }), + Encoding.UTF8, "application/json"), + ct); + + var provBody = await provResp.Content.ReadAsStringAsync(ct); + using var provDoc = JsonDocument.Parse(provBody); + var pv = provDoc.RootElement; + + if (provResp.IsSuccessStatusCode && Bol(pv, "ok")) + { + var extId = Str(pv, "externalAccountId"); + var loginId = Str(pv, "loginAccountId"); + + await Sql.ExecProcAsync("dbo.spClientManagement", "recordAdAccount", + Json(new { clientId, network = provider, externalAccountId = extId, loginAccountId = loginId, status = "Active" }), + ct: ct); + + providerResults.Add(new { provider, status = "Succeeded", externalAccountId = extId }); + Logger.LogInformation("[Approve] {Provider} account created: {ExtId}", provider, extId); + } + else + { + var err = Str(pv, "error") ?? $"HTTP {(int)provResp.StatusCode}"; + providerResults.Add(new { provider, status = "Failed", error = err }); + Logger.LogWarning("[Approve] {Provider} failed: {Error}", provider, err); + } + } + catch (Exception ex) + { + providerResults.Add(new { provider, status = "Failed", error = ex.Message }); + Logger.LogError(ex, "[Approve] {Provider} threw", provider); + } + } + } + + // ── 5. Queue notifications ──────────────────────────────────────── + if (wasCreated && !string.IsNullOrWhiteSpace(regEmail)) + { + await Sql.ExecProcAsync("dbo.spNotification", "queue", + Json(new + { + type = "approval_welcome", + toEmail = regEmail, + toName = regName, + subject = $"Your AdPlatform account is approved — {bizName}", + bodyJson = Json(new { clientId, clientName = bizName, providerResults }) + }), ct: ct); + + var failures = providerResults + .Where(r => { using var d = JsonDocument.Parse(Json(r)); return Str(d.RootElement, "status") == "Failed"; }) + .ToList(); + + if (failures.Any() && !string.IsNullOrWhiteSpace(Client.Email)) + { + await Sql.ExecProcAsync("dbo.spNotification", "queue", + Json(new + { + type = "provisioning_alert", + toEmail = Client.Email, + toName = Client.Email, + subject = $"[AdPlatform] Provisioning issue — {bizName}", + bodyJson = Json(new { clientId, clientName = bizName, failures }) + }), ct: ct); + } + } + + // ── 6. Mark registration complete ───────────────────────────────── + try + { + await _registration.CompleteAsync(registrationId, clientId, ct); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "[Approve] Failed to mark registration {RegId} complete (non-fatal)", registrationId); + } + + return Ok(new { ok = true, clientId, name = bizName, status = "Active", created = wasCreated, providerResults }); + } + + [HttpPut("{clientId}")] + public Task Update(string clientId, [FromBody] JsonElement body, CancellationToken ct) + { + Logger.LogInformation("[Admin] Updating client {Id} by {User}", clientId, Client.Email); + return CallProc(Proc, "updateProfile", new { clientId, - clientName = request?.ClientName?.Trim(), - status = request?.Status + name = Str(body, "name"), + websiteUrl = Str(body, "websiteUrl"), + description = Str(body, "description"), + contactName = Str(body, "contactName"), + contactEmail = Str(body, "contactEmail"), + contactPhone = Str(body, "contactPhone"), + notes = Str(body, "notes"), + category = Str(body, "clientCategory") }, ct); } - /// - /// Deactivate client (soft delete). - /// - [HttpDelete("{clientId}")] - public Task Delete(string clientId, CancellationToken ct) + [HttpPost("{clientId}/suspend")] + public Task Suspend(string clientId, [FromBody] JsonElement body, CancellationToken ct) { - Logger.LogWarning("[Admin] DeleteClient | Id={Id} | By={User}", clientId, Client.Email); - return CallProc("spAdminClients", "delete", new { clientId }, ct); + Logger.LogWarning("[Admin] Suspending {Id} by {User}", clientId, Client.Email); + return CallProc(Proc, "updateStatus", new + { + clientId, newStatus = "Suspended", + reason = Str(body, "reason"), changedByEmail = Client.Email + }, ct); } -} -// DTOs -public sealed class CreateClientRequest -{ - public string? ClientName { get; set; } -} + [HttpPost("{clientId}/cancel")] + public Task Cancel(string clientId, [FromBody] JsonElement body, CancellationToken ct) + { + Logger.LogWarning("[Admin] Cancelling {Id} by {User}", clientId, Client.Email); + return CallProc(Proc, "updateStatus", new + { + clientId, newStatus = "Cancelled", + reason = Str(body, "reason"), changedByEmail = Client.Email + }, ct); + } -public sealed class UpdateClientRequest -{ - public string? ClientName { get; set; } - public string? Status { get; set; } + [HttpPost("{clientId}/reactivate")] + public Task Reactivate(string clientId, CancellationToken ct) + { + Logger.LogInformation("[Admin] Reactivating {Id} by {User}", clientId, Client.Email); + return CallProc(Proc, "updateStatus", new + { + clientId, newStatus = "Active", changedByEmail = Client.Email + }, ct); + } + + [HttpGet("{clientId}/defaults")] + public Task Defaults(string clientId, CancellationToken ct) + => CallProc(Proc, "defaults", new { clientId }, ct); + + // ── Registration Proxy ──────────────────────────────────────────────── + + [HttpGet("/api/registration/pending")] + public async Task GetPending(CancellationToken ct) + { + var result = await _registration.GetPendingAsync(ct); + if (result == null) + return StatusCode(502, new { ok = false, error = "Registration service unavailable" }); + return Content(result.RootElement.GetRawText(), "application/json"); + } + + [HttpGet("/api/registration/{registrationId}")] + public async Task GetById(string registrationId, CancellationToken ct) + { + var result = await _registration.GetByIdAsync(registrationId, ct); + if (result == null) + return StatusCode(502, new { ok = false, error = "Registration service unavailable" }); + return Content(result.RootElement.GetRawText(), "application/json"); + } + + [HttpPost("/api/registration/{registrationId}/reject")] + public async Task Reject(string registrationId, [FromBody] JsonElement body, CancellationToken ct) + { + Logger.LogInformation("[Admin] Rejecting {Id} by {User}", registrationId, Client.Email); + var result = await _registration.RejectAsync(registrationId, Str(body, "reason"), ct); + if (result == null) + return StatusCode(502, new { ok = false, error = "Registration service unavailable" }); + return Content(result.RootElement.GetRawText(), "application/json"); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static string? Str(JsonElement el, string key) => + el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; + + private static bool Bol(JsonElement el, string key) => + el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.True; + + private static string Json(object o) => + JsonSerializer.Serialize(o, JsonOpts); } diff --git a/Management/Controllers/Admin/AdminControllerBase.cs b/Management/Controllers/Admin/AdminControllerBase.cs index 35eba2b..a60dcbf 100644 --- a/Management/Controllers/Admin/AdminControllerBase.cs +++ b/Management/Controllers/Admin/AdminControllerBase.cs @@ -24,11 +24,16 @@ public abstract class AdminControllerBase : ControllerBase /// /// Execute stored procedure and return appropriate IActionResult. /// - protected async Task CallProc(string proc, string action, object rqst, CancellationToken ct) + protected Task CallProc(string proc, string action, string rqst, CancellationToken ct) + => CallProcInternal(proc, action, rqst, ct); + + protected Task CallProc(string proc, string action, object rqst, CancellationToken ct) + => CallProcInternal(proc, action, JsonSerializer.Serialize(rqst), ct); + + private async Task CallProcInternal(string proc, string action, string json, CancellationToken ct) { try { - var json = JsonSerializer.Serialize(rqst); var resp = await Sql.ExecProcAsync($"dbo.{proc}", action, json, ct: ct); if (string.IsNullOrWhiteSpace(resp)) diff --git a/Management/Controllers/Admin/AdminHelpController.cs b/Management/Controllers/Admin/AdminHelpController.cs new file mode 100644 index 0000000..633d03f --- /dev/null +++ b/Management/Controllers/Admin/AdminHelpController.cs @@ -0,0 +1,50 @@ +using Management.Data; +using Management.Security; +using Management.Controllers.Admin; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Admin CRUD for help content — requires admin session. +/// +[ApiController] +[Route("api/admin/help")] +public class AdminHelpController : AdminControllerBase +{ + public AdminHelpController(SqlService sql, ClientContext client, ILogger logger) + : base(sql, client, logger) { } + + /// + /// GET /api/admin/help + /// List all help content entries (active and inactive). + /// + [HttpPost("list")] + public async Task List([FromBody] JsonElement body, CancellationToken ct) + { + return await CallProc("spHelp", "list", body.ToString(), ct); + } + + /// + /// POST /api/admin/help + /// Create or update a help entry by helpKey (upsert). + /// Body: { helpKey, title, body, isActive } + /// + [HttpPost] + public async Task Upsert([FromBody] JsonElement payload, CancellationToken ct) + { + return await CallProc("spHelp", "upsert", payload, ct); + } + + /// + /// DELETE /api/admin/help/{key} + /// Delete a help entry by key. + /// + [HttpDelete("{key}")] + public async Task Delete(string key, CancellationToken ct) + { + return await CallProc("spHelp", "delete", + new { helpKey = key, adminId = Client.UserId }, ct); + } +} diff --git a/Management/Controllers/Admin/AdminMetricSyncController.cs b/Management/Controllers/Admin/AdminMetricSyncController.cs new file mode 100644 index 0000000..55a0bfe --- /dev/null +++ b/Management/Controllers/Admin/AdminMetricSyncController.cs @@ -0,0 +1,112 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Admin endpoint for manually triggering metric sync. +/// +/// ENDPOINTS: +/// POST /api/admin/sync/metrics/{clientId} - Sync a specific client +/// POST /api/admin/sync/metrics - Sync all active clients +/// +/// Proxies to Gateway /api/sync/metrics/* using an internal service key. +/// Gateway owns provider connectivity; Management owns the trigger surface. +/// Configure via GATEWAY_URL + INTERNAL_SERVICE_KEY env vars. +/// +[ApiController] +[Route("api/admin/sync")] +public sealed class AdminMetricSyncController : AdminControllerBase +{ + private readonly IHttpClientFactory _http; + private readonly IConfiguration _config; + + public AdminMetricSyncController( + SqlService sql, + ClientContext client, + ILogger log, + IHttpClientFactory http, + IConfiguration config) + : base(sql, client, log) + { + _http = http; + _config = config; + } + + /// + /// Trigger metric sync for a specific client. + /// + [HttpPost("metrics/{clientId}")] + public async Task SyncClient( + string clientId, + [FromQuery] string? startDate, + [FromQuery] string? endDate, + CancellationToken ct) + { + Logger.LogInformation("[Admin] MetricSync triggered for client {ClientId} | By={User}", + clientId, Client.Email); + + return await ProxyToGateway($"metrics/{Uri.EscapeDataString(clientId)}", startDate, endDate, ct); + } + + /// + /// Trigger metric sync for all active clients. + /// + [HttpPost("metrics")] + public async Task SyncAll( + [FromQuery] string? startDate, + [FromQuery] string? endDate, + CancellationToken ct) + { + Logger.LogInformation("[Admin] MetricSync ALL clients triggered | By={User}", Client.Email); + + return await ProxyToGateway("metrics/all", startDate, endDate, ct); + } + + // ──────────────────────────────────────────────── + // Proxy helper + // ──────────────────────────────────────────────── + + private async Task ProxyToGateway( + string path, string? startDate, string? endDate, CancellationToken ct) + { + var gatewayUrl = _config["GATEWAY_URL"]?.TrimEnd('/'); + var serviceKey = _config["INTERNAL_SERVICE_KEY"]; + + if (string.IsNullOrWhiteSpace(gatewayUrl)) + return StatusCode(500, new { ok = false, error = "GATEWAY_URL not configured" }); + if (string.IsNullOrWhiteSpace(serviceKey)) + return StatusCode(500, new { ok = false, error = "INTERNAL_SERVICE_KEY not configured" }); + + var qs = new List(); + if (!string.IsNullOrWhiteSpace(startDate)) qs.Add($"startDate={Uri.EscapeDataString(startDate)}"); + if (!string.IsNullOrWhiteSpace(endDate)) qs.Add($"endDate={Uri.EscapeDataString(endDate)}"); + + var url = $"{gatewayUrl}/api/sync/{path}"; + if (qs.Count > 0) url += "?" + string.Join("&", qs); + + try + { + var client = _http.CreateClient(); + using var req = new HttpRequestMessage(HttpMethod.Post, url); + req.Headers.Add("X-Service-Key", serviceKey); + + using var resp = await client.SendAsync(req, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + Logger.LogInformation("[Admin] MetricSync Gateway response {Status} | Path={Path}", + (int)resp.StatusCode, path); + + return resp.IsSuccessStatusCode + ? Content(body, "application/json") + : StatusCode((int)resp.StatusCode, new { ok = false, error = $"Gateway returned {(int)resp.StatusCode}", detail = body }); + } + catch (Exception ex) + { + Logger.LogError(ex, "[Admin] MetricSync proxy error | Path={Path}", path); + return StatusCode(500, new { ok = false, error = "Sync trigger failed", detail = ex.Message }); + } + } +} diff --git a/Management/Controllers/Admin/AdminModifiersController.cs b/Management/Controllers/Admin/AdminModifiersController.cs new file mode 100644 index 0000000..a6a03e1 --- /dev/null +++ b/Management/Controllers/Admin/AdminModifiersController.cs @@ -0,0 +1,84 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; + +namespace Management.Controllers.Admin; + +/// +/// Admin endpoints for audience-based allocation modifiers. +/// Table: dbo.tbAllocationModifier +/// Proc: dbo.spAllocationRecommend (list / update actions) +/// +/// ENDPOINTS: +/// GET /api/admin/modifiers - List all modifiers +/// PUT /api/admin/modifiers/{id} - Update a modifier +/// POST /api/admin/modifiers/preview - Preview recommendation with factors +/// +[ApiController] +[Route("api/admin/modifiers")] +public sealed class AdminModifiersController : AdminControllerBase +{ + public AdminModifiersController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + [HttpGet] + public Task List(CancellationToken ct) + => CallProc("spAllocationRecommend", "list", new { }, ct); + + [HttpPut("{id:int}")] + public Task Update(int id, [FromBody] UpdateModifierRequest request, CancellationToken ct) + { + if (request?.PctAdjustment is < -50 or > 50) + return Task.FromResult(ValidationError("pctAdjustment must be between -50 and 50")); + + Logger.LogWarning("[Admin] UpdateModifier | Id={Id} | By={User}", id, Client.Email); + + return CallProc("spAllocationRecommend", "update", new + { + id, + pctAdjustment = request?.PctAdjustment, + minBudgetAdj = request?.MinBudgetAdj, + rationale = request?.Rationale?.Trim(), + isActive = request?.IsActive + }, ct); + } + + /// + /// Preview a recommendation with given factors — same proc, recommend action. + /// Lets admins test how modifiers affect channel mix without going through the wizard. + /// + [HttpPost("preview")] + public Task Preview([FromBody] PreviewRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request?.BusinessCategory)) + return Task.FromResult(ValidationError("businessCategory is required")); + if (string.IsNullOrWhiteSpace(request?.Objective)) + return Task.FromResult(ValidationError("objective is required")); + + return CallProc("spAllocationRecommend", "recommend", new + { + businessCategory = request.BusinessCategory.Trim(), + objective = request.Objective.Trim(), + ageSkew = request.AgeSkew?.Trim(), + marketScope = request.MarketScope?.Trim() + }, ct); + } +} + +// DTOs + +public sealed class UpdateModifierRequest +{ + public int? PctAdjustment { get; set; } + public int? MinBudgetAdj { get; set; } + public string? Rationale { get; set; } + public bool? IsActive { get; set; } +} + +public sealed class PreviewRequest +{ + public string? BusinessCategory { get; set; } + public string? Objective { get; set; } + public string? AgeSkew { get; set; } + public string? MarketScope { get; set; } +} diff --git a/Management/Controllers/Admin/AdminObjectiveMappingController.cs b/Management/Controllers/Admin/AdminObjectiveMappingController.cs new file mode 100644 index 0000000..83be92c --- /dev/null +++ b/Management/Controllers/Admin/AdminObjectiveMappingController.cs @@ -0,0 +1,120 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Admin endpoints for objective-to-channel mapping management. +/// Table: dbo.tbObjectiveMapping +/// Maps platform objectives to provider-specific objectives with capability flags. +/// +/// ENDPOINTS: +/// GET /api/admin/objectives - List mappings (filterable) +/// GET /api/admin/objectives/{id} - Get mapping +/// POST /api/admin/objectives - Create mapping +/// PUT /api/admin/objectives/{id} - Update mapping +/// DELETE /api/admin/objectives/{id} - Delete mapping +/// +[ApiController] +[Route("api/admin/objectives")] +public sealed class AdminObjectiveMappingController : AdminControllerBase +{ + public AdminObjectiveMappingController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + [HttpPost("list")] + public Task List([FromBody] JsonElement body, CancellationToken ct) + => CallProc("spAdminObjectiveMapping", "list", body.ToString(), ct); + + [HttpGet("{mappingId:int}")] + public Task Get(int mappingId, CancellationToken ct) + => CallProc("spAdminObjectiveMapping", "get", new { mappingId }, ct); + + [HttpPost] + public Task Create([FromBody] CreateMappingRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request?.ChannelType)) + return Task.FromResult(ValidationError("channelType is required")); + if (string.IsNullOrWhiteSpace(request?.PlatformObjective)) + return Task.FromResult(ValidationError("platformObjective is required")); + if (string.IsNullOrWhiteSpace(request?.ProviderObjective)) + return Task.FromResult(ValidationError("providerObjective is required")); + if (string.IsNullOrWhiteSpace(request?.ProviderObjectiveLabel)) + return Task.FromResult(ValidationError("providerObjectiveLabel is required")); + + Logger.LogWarning("[Admin] CreateObjectiveMapping | {Objective} → {Channel}/{Provider} | By={User}", + request.PlatformObjective, request.ChannelType, request.ProviderObjective, Client.Email); + + return CallProc("spAdminObjectiveMapping", "create", new + { + channelType = request.ChannelType.Trim(), + platformObjective = request.PlatformObjective.Trim(), + providerObjective = request.ProviderObjective.Trim(), + providerObjectiveLabel = request.ProviderObjectiveLabel.Trim(), + supportsObjectiveChange = request.SupportsObjectiveChange ?? false, + supportsBudgetChange = request.SupportsBudgetChange ?? true, + supportsTargetingChange = request.SupportsTargetingChange ?? true, + supportsStatusToggle = request.SupportsStatusToggle ?? true, + notes = request.Notes?.Trim() + }, ct); + } + + [HttpPut("{mappingId:int}")] + public Task Update(int mappingId, [FromBody] UpdateMappingRequest request, CancellationToken ct) + { + Logger.LogWarning("[Admin] UpdateObjectiveMapping | Id={Id} | By={User}", mappingId, Client.Email); + + return CallProc("spAdminObjectiveMapping", "update", new + { + mappingId, + channelType = request?.ChannelType?.Trim(), + platformObjective = request?.PlatformObjective?.Trim(), + providerObjective = request?.ProviderObjective?.Trim(), + providerObjectiveLabel = request?.ProviderObjectiveLabel?.Trim(), + supportsObjectiveChange = request?.SupportsObjectiveChange, + supportsBudgetChange = request?.SupportsBudgetChange, + supportsTargetingChange = request?.SupportsTargetingChange, + supportsStatusToggle = request?.SupportsStatusToggle, + notes = request?.Notes?.Trim(), + isActive = request?.IsActive + }, ct); + } + + [HttpDelete("{mappingId:int}")] + public Task Delete(int mappingId, CancellationToken ct) + { + Logger.LogWarning("[Admin] DeleteObjectiveMapping | Id={Id} | By={User}", mappingId, Client.Email); + return CallProc("spAdminObjectiveMapping", "delete", new { mappingId }, ct); + } +} + +// DTOs + +public sealed class CreateMappingRequest +{ + public string? ChannelType { get; set; } + public string? PlatformObjective { get; set; } + public string? ProviderObjective { get; set; } + public string? ProviderObjectiveLabel { get; set; } + public bool? SupportsObjectiveChange { get; set; } + public bool? SupportsBudgetChange { get; set; } + public bool? SupportsTargetingChange { get; set; } + public bool? SupportsStatusToggle { get; set; } + public string? Notes { get; set; } +} + +public sealed class UpdateMappingRequest +{ + public string? ChannelType { get; set; } + public string? PlatformObjective { get; set; } + public string? ProviderObjective { get; set; } + public string? ProviderObjectiveLabel { get; set; } + public bool? SupportsObjectiveChange { get; set; } + public bool? SupportsBudgetChange { get; set; } + public bool? SupportsTargetingChange { get; set; } + public bool? SupportsStatusToggle { get; set; } + public string? Notes { get; set; } + public bool? IsActive { get; set; } +} diff --git a/Management/Controllers/Admin/AdminRecommendationsController.cs b/Management/Controllers/Admin/AdminRecommendationsController.cs new file mode 100644 index 0000000..aad4694 --- /dev/null +++ b/Management/Controllers/Admin/AdminRecommendationsController.cs @@ -0,0 +1,179 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Admin endpoints for the recommendation engine. +/// +/// ENDPOINTS: +/// GET /api/admin/recommendations/rules - List all rules +/// GET /api/admin/recommendations/rules/{id} - Get rule +/// POST /api/admin/recommendations/rules - Create rule +/// PUT /api/admin/recommendations/rules/{id} - Update rule +/// DELETE /api/admin/recommendations/rules/{id} - Delete rule +/// POST /api/admin/recommendations/evaluate - Trigger evaluation +/// POST /api/admin/recommendations/cleanup - Cleanup old records +/// +/// Client-facing endpoints (list, dismiss, resolve) remain on Gateway +/// at /api/recommendations — scoped to the authenticated CIAM session. +/// +[ApiController] +[Route("api/admin/recommendations")] +public sealed class AdminRecommendationsController : AdminControllerBase +{ + public AdminRecommendationsController( + SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + // ──────────────────────────────────────────────── + // Rule Management + // ──────────────────────────────────────────────── + + [HttpGet("rules")] + public Task ListRules( + [FromQuery] string? category, + [FromQuery] string? channel, + CancellationToken ct) + => CallProc("spRecommendation", "rules.list", new { category, channel }, ct); + + [HttpGet("rules/{ruleId:int}")] + public Task GetRule(int ruleId, CancellationToken ct) + => CallProc("spRecommendation", "rules.get", new { ruleId }, ct); + + [HttpPost("rules")] + public Task CreateRule([FromBody] RuleRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request?.Code)) + return Task.FromResult(ValidationError("code is required")); + if (string.IsNullOrWhiteSpace(request.Name)) + return Task.FromResult(ValidationError("name is required")); + + Logger.LogInformation("[Admin] CreateRecommendationRule {Code} | By={User}", request.Code, Client.Email); + + return CallProc("spRecommendation", "rules.create", new + { + request.Code, + request.Name, + request.Category, + request.Metric, + request.Operator, + request.Threshold, + request.ThresholdType, + request.Severity, + request.Channel, + request.Objective, + request.Message, + request.AdminNotes, + request.LookbackDays, + request.MinDataDays, + request.CooldownHours, + request.SortOrder + }, ct); + } + + [HttpPut("rules/{ruleId:int}")] + public Task UpdateRule(int ruleId, [FromBody] RuleRequest request, CancellationToken ct) + { + Logger.LogInformation("[Admin] UpdateRecommendationRule {Id} | By={User}", ruleId, Client.Email); + + return CallProc("spRecommendation", "rules.update", new + { + ruleId, + request.Name, + request.Category, + request.Metric, + request.Operator, + request.Threshold, + request.ThresholdType, + request.Severity, + request.Channel, + request.Objective, + request.Message, + request.AdminNotes, + request.LookbackDays, + request.MinDataDays, + request.CooldownHours, + request.IsActive, + request.SortOrder + }, ct); + } + + [HttpDelete("rules/{ruleId:int}")] + public Task DeleteRule(int ruleId, CancellationToken ct) + { + Logger.LogInformation("[Admin] DeleteRecommendationRule {Id} | By={User}", ruleId, Client.Email); + return CallProc("spRecommendation", "rules.delete", new { ruleId }, ct); + } + + // ──────────────────────────────────────────────── + // Evaluation Engine + // ──────────────────────────────────────────────── + + /// + /// Trigger rule evaluation for a campaign, initiative, client, or all active campaigns. + /// + [HttpPost("evaluate")] + public Task Evaluate([FromBody] EvaluateRequest? request, CancellationToken ct) + { + Logger.LogInformation("[Admin] Evaluate | ClientId={Client} InitiativeId={Init} | By={User}", + request?.ClientId, request?.InitiativeId, Client.Email); + + return CallProc("spRecommendation", "evaluate", new + { + channelCampaignId = request?.ChannelCampaignId, + initiativeId = request?.InitiativeId, + clientId = request?.ClientId + }, ct); + } + + /// + /// Cleanup expired and old recommendations. + /// + [HttpPost("cleanup")] + public Task Cleanup([FromBody] CleanupRequest? request, CancellationToken ct) + { + Logger.LogInformation("[Admin] RecommendationCleanup daysToKeep={Days} | By={User}", + request?.DaysToKeep ?? 90, Client.Email); + + return CallProc("spRecommendation", "cleanup", + new { daysToKeep = request?.DaysToKeep ?? 90 }, ct); + } +} + +// ── Request DTOs ── + +public sealed class RuleRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Category { get; set; } + public string? Metric { get; set; } + public string? Operator { get; set; } + public decimal? Threshold { get; set; } + public string? ThresholdType { get; set; } + public string? Severity { get; set; } + public string? Channel { get; set; } + public string? Objective { get; set; } + public string? Message { get; set; } + public string? AdminNotes { get; set; } + public int? LookbackDays { get; set; } + public int? MinDataDays { get; set; } + public int? CooldownHours { get; set; } + public bool? IsActive { get; set; } + public int? SortOrder { get; set; } +} + +public sealed class EvaluateRequest +{ + public long? ChannelCampaignId { get; set; } + public long? InitiativeId { get; set; } + public string? ClientId { get; set; } +} + +public sealed class CleanupRequest +{ + public int? DaysToKeep { get; set; } +} diff --git a/Management/Controllers/Admin/AdminSessionsController.cs b/Management/Controllers/Admin/AdminSessionsController.cs index f600bfd..6ff870d 100644 --- a/Management/Controllers/Admin/AdminSessionsController.cs +++ b/Management/Controllers/Admin/AdminSessionsController.cs @@ -1,6 +1,7 @@ using Management.Data; using Management.Security; using Microsoft.AspNetCore.Mvc; +using System.Text.Json; namespace Management.Controllers.Admin; @@ -24,14 +25,9 @@ public sealed class AdminSessionsController : AdminControllerBase /// /// List sessions with optional filtering. /// - [HttpGet] - public Task List( - [FromQuery] string? clientId, - [FromQuery] string? userId, - [FromQuery] bool activeOnly = true, - [FromQuery] int limit = 100, - CancellationToken ct = default) - => CallProc("spAdminSessions", "list", new { clientId, userId, activeOnly, limit }, ct); + [HttpPost("list")] + public Task List([FromBody] JsonElement body, CancellationToken ct) + => CallProc("spAdminSessions", "list", body.ToString(), ct); /// /// Revoke a session. @@ -57,9 +53,10 @@ public sealed class AdminSessionsController : AdminControllerBase /// Cleanup expired sessions. /// [HttpPost("cleanup")] - public Task Cleanup([FromQuery] int daysOld = 30, CancellationToken ct = default) + public Task Cleanup([FromBody] JsonElement body, CancellationToken ct) { + var daysOld = body.TryGetProperty("daysOld", out var d) ? d.GetInt32() : 30; Logger.LogWarning("[Admin] CleanupSessions | DaysOld={DaysOld} | By={User}", daysOld, Client.Email); - return CallProc("spAdminSessions", "cleanup", new { daysOld }, ct); + return CallProc("spAdminSessions", "cleanup", body.ToString(), ct); } } diff --git a/Management/Controllers/Admin/AdminTemplateConfigController.cs b/Management/Controllers/Admin/AdminTemplateConfigController.cs new file mode 100644 index 0000000..f84aec6 --- /dev/null +++ b/Management/Controllers/Admin/AdminTemplateConfigController.cs @@ -0,0 +1,220 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; + +namespace Management.Controllers.Admin; + +/// +/// Admin endpoints for template configuration metadata — +/// business categories and objective display properties. +/// +/// Both are managed through a single stored procedure +/// (spAdminTemplateConfig) with action-based routing. +/// +/// CHANNEL ENDPOINTS: +/// GET /api/admin/template-config/channels - List channels (with mapping/template counts) +/// GET /api/admin/template-config/channels/{id} - Get channel +/// POST /api/admin/template-config/channels - Create channel +/// PUT /api/admin/template-config/channels/{id} - Update channel (code rename cascades to mappings + templates) +/// DELETE /api/admin/template-config/channels/{id} - Delete channel (blocked if references exist) +/// +/// CATEGORY ENDPOINTS: +/// GET /api/admin/template-config/categories - List categories (with template counts) +/// GET /api/admin/template-config/categories/{id} - Get category +/// POST /api/admin/template-config/categories - Create category +/// PUT /api/admin/template-config/categories/{id} - Update category (rename cascades to templates) +/// DELETE /api/admin/template-config/categories/{id} - Delete category (blocked if templates exist) +/// +/// OBJECTIVE ENDPOINTS: +/// GET /api/admin/template-config/objectives - List objectives (with template counts + mapping status) +/// GET /api/admin/template-config/objectives/{id} - Get objective +/// PUT /api/admin/template-config/objectives/{id} - Update objective display properties (color, sort) +/// +/// NOTE: Objectives are a controlled set — they cannot be created or deleted +/// because they must stay in sync with tbObjectiveMapping and spInitiative +/// validation. Only display properties (color, sortOrder, isActive) are editable. +/// +[ApiController] +[Route("api/admin/template-config")] +public sealed class AdminTemplateConfigController : AdminControllerBase +{ + public AdminTemplateConfigController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + // ═══════════════════════════════════════════════════════════ + // CATEGORIES + // ═══════════════════════════════════════════════════════════ + + [HttpGet("categories")] + public Task ListCategories(CancellationToken ct = default) + => CallProc("spAdminTemplateConfig", "categories.list", new { }, ct); + + [HttpGet("categories/{categoryId:int}")] + public Task GetCategory(int categoryId, CancellationToken ct) + => CallProc("spAdminTemplateConfig", "categories.get", new { categoryId }, ct); + + [HttpPost("categories")] + public Task CreateCategory([FromBody] CreateCategoryRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request?.Name)) + return Task.FromResult(ValidationError("name is required")); + + Logger.LogWarning("[Admin] CreateCategory | Name={Name} | By={User}", + request.Name, Client.Email); + + return CallProc("spAdminTemplateConfig", "categories.create", new + { + name = request.Name.Trim(), + icon = request.Icon?.Trim(), + sortOrder = request.SortOrder + }, ct); + } + + [HttpPut("categories/{categoryId:int}")] + public Task UpdateCategory(int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct) + { + Logger.LogWarning("[Admin] UpdateCategory | Id={Id} | By={User}", categoryId, Client.Email); + + return CallProc("spAdminTemplateConfig", "categories.update", new + { + categoryId, + name = request?.Name?.Trim(), + icon = request?.Icon?.Trim(), + sortOrder = request?.SortOrder, + isActive = request?.IsActive + }, ct); + } + + [HttpDelete("categories/{categoryId:int}")] + public Task DeleteCategory(int categoryId, CancellationToken ct) + { + Logger.LogWarning("[Admin] DeleteCategory | Id={Id} | By={User}", categoryId, Client.Email); + return CallProc("spAdminTemplateConfig", "categories.delete", new { categoryId }, ct); + } + + // ═══════════════════════════════════════════════════════════ + // OBJECTIVES + // ═══════════════════════════════════════════════════════════ + + [HttpGet("objectives")] + public Task ListObjectives(CancellationToken ct = default) + => CallProc("spAdminTemplateConfig", "objectives.list", new { }, ct); + + [HttpGet("objectives/{objectiveId:int}")] + public Task GetObjective(int objectiveId, CancellationToken ct) + => CallProc("spAdminTemplateConfig", "objectives.get", new { objectiveId }, ct); + + [HttpPut("objectives/{objectiveId:int}")] + public Task UpdateObjective(int objectiveId, [FromBody] UpdateObjectiveRequest request, CancellationToken ct) + { + Logger.LogWarning("[Admin] UpdateObjective | Id={Id} | By={User}", objectiveId, Client.Email); + + return CallProc("spAdminTemplateConfig", "objectives.update", new + { + objectiveId, + color = request?.Color?.Trim(), + sortOrder = request?.SortOrder, + isActive = request?.IsActive + }, ct); + } + + // ═══════════════════════════════════════════════════════════ + // CHANNELS + // ═══════════════════════════════════════════════════════════ + + [HttpGet("channels")] + public Task ListChannels(CancellationToken ct = default) + => CallProc("spAdminTemplateConfig", "channels.list", new { }, ct); + + [HttpGet("channels/{channelId:int}")] + public Task GetChannel(int channelId, CancellationToken ct) + => CallProc("spAdminTemplateConfig", "channels.get", new { channelId }, ct); + + [HttpPost("channels")] + public Task CreateChannel([FromBody] CreateChannelRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request?.Code)) + return Task.FromResult(ValidationError("code is required")); + if (string.IsNullOrWhiteSpace(request?.Label)) + return Task.FromResult(ValidationError("label is required")); + + Logger.LogWarning("[Admin] CreateChannel | Code={Code} | By={User}", + request.Code, Client.Email); + + return CallProc("spAdminTemplateConfig", "channels.create", new + { + code = request.Code.Trim().ToLowerInvariant(), + label = request.Label.Trim(), + color = request.Color?.Trim(), + icon = request.Icon?.Trim(), + sortOrder = request.SortOrder + }, ct); + } + + [HttpPut("channels/{channelId:int}")] + public Task UpdateChannel(int channelId, [FromBody] UpdateChannelRequest request, CancellationToken ct) + { + Logger.LogWarning("[Admin] UpdateChannel | Id={Id} | By={User}", channelId, Client.Email); + + return CallProc("spAdminTemplateConfig", "channels.update", new + { + channelId, + code = request?.Code?.Trim()?.ToLowerInvariant(), + label = request?.Label?.Trim(), + color = request?.Color?.Trim(), + icon = request?.Icon?.Trim(), + sortOrder = request?.SortOrder, + isActive = request?.IsActive + }, ct); + } + + [HttpDelete("channels/{channelId:int}")] + public Task DeleteChannel(int channelId, CancellationToken ct) + { + Logger.LogWarning("[Admin] DeleteChannel | Id={Id} | By={User}", channelId, Client.Email); + return CallProc("spAdminTemplateConfig", "channels.delete", new { channelId }, ct); + } +} + +// ─── DTOs ─────────────────────────────────────────────────── + +public sealed class CreateCategoryRequest +{ + public string? Name { get; set; } + public string? Icon { get; set; } + public int? SortOrder { get; set; } +} + +public sealed class UpdateCategoryRequest +{ + public string? Name { get; set; } + public string? Icon { get; set; } + public int? SortOrder { get; set; } + public bool? IsActive { get; set; } +} + +public sealed class UpdateObjectiveRequest +{ + public string? Color { get; set; } + public int? SortOrder { get; set; } + public bool? IsActive { get; set; } +} + +public sealed class CreateChannelRequest +{ + public string? Code { get; set; } + public string? Label { get; set; } + public string? Color { get; set; } + public string? Icon { get; set; } + public int? SortOrder { get; set; } +} + +public sealed class UpdateChannelRequest +{ + public string? Code { get; set; } + public string? Label { get; set; } + public string? Color { get; set; } + public string? Icon { get; set; } + public int? SortOrder { get; set; } + public bool? IsActive { get; set; } +} diff --git a/Management/Controllers/Admin/AdminTemplatesController.cs b/Management/Controllers/Admin/AdminTemplatesController.cs new file mode 100644 index 0000000..b3a4b1d --- /dev/null +++ b/Management/Controllers/Admin/AdminTemplatesController.cs @@ -0,0 +1,115 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Admin endpoints for allocation template management. +/// Table: dbo.tbAllocationTemplate +/// +/// ENDPOINTS: +/// GET /api/admin/templates - List templates (filterable) +/// GET /api/admin/templates/{id} - Get template +/// POST /api/admin/templates - Create template +/// PUT /api/admin/templates/{id} - Update template +/// DELETE /api/admin/templates/{id} - Delete template +/// GET /api/admin/templates/categories - Distinct business categories +/// +[ApiController] +[Route("api/admin/templates")] +public sealed class AdminTemplatesController : AdminControllerBase +{ + public AdminTemplatesController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + [HttpPost("list")] + public Task List([FromBody] JsonElement body, CancellationToken ct) + => CallProc("spAdminTemplates", "list", body.ToString(), ct); + + [HttpGet("{templateId:int}")] + public Task Get(int templateId, CancellationToken ct) + => CallProc("spAdminTemplates", "get", new { templateId }, ct); + + [HttpPost] + public Task Create([FromBody] CreateTemplateRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request?.ChannelType)) + return Task.FromResult(ValidationError("channelType is required")); + if (string.IsNullOrWhiteSpace(request?.BusinessCategory)) + return Task.FromResult(ValidationError("businessCategory is required")); + if (string.IsNullOrWhiteSpace(request?.Objective)) + return Task.FromResult(ValidationError("objective is required")); + if (request.RecommendedPct is null or < 0 or > 100) + return Task.FromResult(ValidationError("recommendedPct must be between 0 and 100")); + + Logger.LogWarning("[Admin] CreateTemplate | {Channel}/{Category}/{Objective} = {Pct}% | By={User}", + request.ChannelType, request.BusinessCategory, request.Objective, request.RecommendedPct, Client.Email); + + return CallProc("spAdminTemplates", "create", new + { + channelType = request.ChannelType.Trim(), + businessCategory = request.BusinessCategory.Trim(), + objective = request.Objective.Trim(), + recommendedPct = request.RecommendedPct, + minBudgetRequired = request.MinBudgetRequired ?? 0m, + rationale = request.Rationale?.Trim() + }, ct); + } + + [HttpPut("{templateId:int}")] + public Task Update(int templateId, [FromBody] UpdateTemplateRequest request, CancellationToken ct) + { + if (request?.RecommendedPct is < 0 or > 100) + return Task.FromResult(ValidationError("recommendedPct must be between 0 and 100")); + + Logger.LogWarning("[Admin] UpdateTemplate | Id={Id} | By={User}", templateId, Client.Email); + + return CallProc("spAdminTemplates", "update", new + { + templateId, + channelType = request?.ChannelType?.Trim(), + businessCategory = request?.BusinessCategory?.Trim(), + objective = request?.Objective?.Trim(), + recommendedPct = request?.RecommendedPct, + minBudgetRequired = request?.MinBudgetRequired, + rationale = request?.Rationale?.Trim(), + isActive = request?.IsActive + }, ct); + } + + [HttpDelete("{templateId:int}")] + public Task Delete(int templateId, CancellationToken ct) + { + Logger.LogWarning("[Admin] DeleteTemplate | Id={Id} | By={User}", templateId, Client.Email); + return CallProc("spAdminTemplates", "delete", new { templateId }, ct); + } + + [HttpGet("categories")] + public Task Categories(CancellationToken ct) + => CallProc("spAdminTemplates", "categories", new { }, ct); +} + +// DTOs + +public sealed class CreateTemplateRequest +{ + public string? ChannelType { get; set; } + public string? BusinessCategory { get; set; } + public string? Objective { get; set; } + public decimal? RecommendedPct { get; set; } + public decimal? MinBudgetRequired { get; set; } + public string? Rationale { get; set; } +} + +public sealed class UpdateTemplateRequest +{ + public string? ChannelType { get; set; } + public string? BusinessCategory { get; set; } + public string? Objective { get; set; } + public decimal? RecommendedPct { get; set; } + public decimal? MinBudgetRequired { get; set; } + public string? Rationale { get; set; } + public bool? IsActive { get; set; } +} diff --git a/Management/Controllers/Admin/AdminUsersController.cs b/Management/Controllers/Admin/AdminUsersController.cs deleted file mode 100644 index 728cf7f..0000000 --- a/Management/Controllers/Admin/AdminUsersController.cs +++ /dev/null @@ -1,140 +0,0 @@ -using Management.Data; -using Management.Security; -using Microsoft.AspNetCore.Mvc; - -namespace Management.Controllers.Admin; - -/// -/// Admin endpoints for user management. -/// Requires Admin role. -/// -/// ENDPOINTS: -/// GET /api/admin/users - List users -/// GET /api/admin/users/{id} - Get user -/// POST /api/admin/users - Create user -/// PUT /api/admin/users/{id} - Update user -/// DELETE /api/admin/users/{id} - Deactivate user -/// POST /api/admin/users/{id}/clients - Link user to client -/// DELETE /api/admin/users/{id}/clients/{cltId} - Unlink user from client -/// -[ApiController] -[Route("api/admin/users")] -public sealed class AdminUsersController : AdminControllerBase -{ - public AdminUsersController(SqlService sql, ClientContext client, ILogger log) - : base(sql, client, log) { } - - /// - /// List users with optional filtering. - /// - [HttpGet] - public Task List( - [FromQuery] string? status, - [FromQuery] string? clientId, - [FromQuery] int page = 1, - [FromQuery] int pageSize = 50, - CancellationToken ct = default) - => CallProc("spAdminUsers", "list", new { status, clientId, page, pageSize }, ct); - - /// - /// Get user by ID. - /// - [HttpGet("{userId}")] - public Task Get(string userId, CancellationToken ct) - => CallProc("spAdminUsers", "get", new { userId }, ct); - - /// - /// Create a new user. - /// - [HttpPost] - public Task Create([FromBody] CreateUserRequest request, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(request?.Email)) - return Task.FromResult(ValidationError("email is required")); - - Logger.LogWarning("[Admin] CreateUser | Email={Email} | By={User}", request.Email, Client.Email); - return CallProc("spAdminUsers", "create", new - { - email = request.Email.Trim(), - displayName = request.DisplayName?.Trim(), - clientId = request.ClientId, - role = request.Role ?? "User" - }, ct); - } - - /// - /// Update user. - /// - [HttpPut("{userId}")] - public Task Update(string userId, [FromBody] UpdateUserRequest request, CancellationToken ct) - { - Logger.LogWarning("[Admin] UpdateUser | Id={Id} | By={User}", userId, Client.Email); - return CallProc("spAdminUsers", "update", new - { - userId, - displayName = request?.DisplayName?.Trim(), - status = request?.Status - }, ct); - } - - /// - /// Deactivate user (soft delete). - /// - [HttpDelete("{userId}")] - public Task Delete(string userId, CancellationToken ct) - { - Logger.LogWarning("[Admin] DeleteUser | Id={Id} | By={User}", userId, Client.Email); - return CallProc("spAdminUsers", "delete", new { userId }, ct); - } - - /// - /// Link user to client with role. - /// - [HttpPost("{userId}/clients")] - public Task LinkToClient(string userId, [FromBody] LinkUserRequest request, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(request?.ClientId)) - return Task.FromResult(ValidationError("clientId is required")); - - Logger.LogWarning("[Admin] LinkUser | UserId={UserId} ClientId={ClientId} | By={User}", - userId, request.ClientId, Client.Email); - return CallProc("spAdminUsers", "linkToClient", new - { - userId, - clientId = request.ClientId, - role = request.Role ?? "User" - }, ct); - } - - /// - /// Unlink user from client. - /// - [HttpDelete("{userId}/clients/{clientId}")] - public Task UnlinkFromClient(string userId, string clientId, CancellationToken ct) - { - Logger.LogWarning("[Admin] UnlinkUser | UserId={UserId} ClientId={ClientId} | By={User}", - userId, clientId, Client.Email); - return CallProc("spAdminUsers", "unlinkFromClient", new { userId, clientId }, ct); - } -} - -// DTOs -public sealed class CreateUserRequest -{ - public string? Email { get; set; } - public string? DisplayName { get; set; } - public string? ClientId { get; set; } - public string? Role { get; set; } -} - -public sealed class UpdateUserRequest -{ - public string? DisplayName { get; set; } - public string? Status { get; set; } -} - -public sealed class LinkUserRequest -{ - public string? ClientId { get; set; } - public string? Role { get; set; } -} diff --git a/Management/Controllers/Admin/DocumentController.cs b/Management/Controllers/Admin/DocumentController.cs new file mode 100644 index 0000000..0692d71 --- /dev/null +++ b/Management/Controllers/Admin/DocumentController.cs @@ -0,0 +1,177 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.SqlClient; +using System.Data; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Internal document management — scope='internal' only. +/// Client-scoped documents are managed through the Gateway. +/// +/// POST /api/documents/list - List internal documents +/// POST /api/documents - Upload internal document +/// GET /api/documents/{id}/download +/// DELETE /api/documents/{id} +/// +[ApiController] +[Route("api/documents")] +public sealed class DocumentController : AdminControllerBase +{ + private readonly IConfiguration _config; + + public DocumentController(SqlService sql, ClientContext client, IConfiguration config, ILogger log) + : base(sql, client, log) + { + _config = config; + } + + // ── POST /api/documents/list ───────────────────────────────────────────── + [HttpPost("list")] + public async Task List([FromBody] JsonElement body, CancellationToken ct) + { + try + { + // Always internal scope for Management API + var rqst = JsonSerializer.Serialize(new { scope = "internal" }); + var result = await Sql.ExecProcAsync("dbo.usp_DocumentList", "list", rqst, ct: ct); + return Content(result, "application/json"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Document list failed"); + return StatusCode(500, new { ok = false, message = ex.Message }); + } + } + + // ── POST /api/documents ────────────────────────────────────────────────── + [HttpPost] + [RequestSizeLimit(52_428_800)] + public async Task Upload( + IFormFile file, + [FromForm] string category, + [FromForm] string? description = null, + CancellationToken ct = default) + { + if (file == null || file.Length == 0) + return BadRequest(new { ok = false, message = "No file provided" }); + + try + { + byte[] fileBytes; + using (var ms = new MemoryStream()) + { + await file.CopyToAsync(ms, ct); + fileBytes = ms.ToArray(); + } + + var rqst = JsonSerializer.Serialize(new + { + docFileName = file.FileName, + docMimeType = file.ContentType, + docFileSize = file.Length, + docCategory = category, + docDescription = description, + docUploadedBy = Client.Email, + docScope = "internal", // Management API = internal only + docCltId = (string?)null + }); + + var result = await ExecUploadAsync(rqst, fileBytes, ct); + return Content(result, "application/json"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Document upload failed: {FileName}", file?.FileName); + return StatusCode(500, new { ok = false, message = ex.Message }); + } + } + + // ── GET /api/documents/{id}/download ───────────────────────────────────── + [HttpGet("{id:long}/download")] + public async Task Download(long id, CancellationToken ct = default) + { + try + { + var cs = _config.GetConnectionString("Sql") + ?? throw new InvalidOperationException("Missing ConnectionStrings:Sql"); + + await using var conn = new SqlConnection(cs); + await conn.OpenAsync(ct); + + await using var cmd = new SqlCommand("dbo.usp_Document", conn) + { + CommandType = CommandType.StoredProcedure, + CommandTimeout = 60 + }; + cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.download" }); + cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = JsonSerializer.Serialize(new { docId = id }) }); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) + return NotFound(new { ok = false, message = "Document not found" }); + + var fileName = reader.GetString(reader.GetOrdinal("docFileName")); + var mimeType = reader.GetString(reader.GetOrdinal("docMimeType")); + var content = (byte[])reader["docContent"]; + + return File(content, mimeType, fileName); + } + catch (Exception ex) + { + Logger.LogError(ex, "Document download failed: docId={DocId}", id); + return StatusCode(500, new { ok = false, message = ex.Message }); + } + } + + // ── DELETE /api/documents/{id} ─────────────────────────────────────────── + [HttpDelete("{id:long}")] + public async Task Delete(long id, CancellationToken ct = default) + { + try + { + Logger.LogInformation("[Documents] Delete docId={DocId} by {User}", id, Client.Email); + var rqst = JsonSerializer.Serialize(new { docId = id }); + var result = await Sql.ExecProcAsync("dbo.usp_Document", "document.delete", rqst, ct: ct); + return Content(result, "application/json"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Document delete failed: docId={DocId}", id); + return StatusCode(500, new { ok = false, message = ex.Message }); + } + } + + // ─── Upload helper: binary @filecontent passed separately ──────────────── + private async Task ExecUploadAsync(string rqst, byte[] fileContent, CancellationToken ct) + { + var cs = _config.GetConnectionString("Sql") + ?? throw new InvalidOperationException("Missing ConnectionStrings:Sql"); + + await using var conn = new SqlConnection(cs); + await conn.OpenAsync(ct); + + await using var cmd = new SqlCommand("dbo.usp_Document", conn) + { + CommandType = CommandType.StoredProcedure, + CommandTimeout = 60 + }; + + cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = "document.upload" }); + cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqst }); + cmd.Parameters.Add(new SqlParameter("@filecontent", SqlDbType.VarBinary, -1) { Value = fileContent }); + + var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1) + { + Direction = ParameterDirection.Output + }; + cmd.Parameters.Add(pResp); + + await cmd.ExecuteNonQueryAsync(ct); + + return pResp.Value as string + ?? JsonSerializer.Serialize(new { ok = false, message = "No response from database" }); + } +} diff --git a/Management/Controllers/AdminReportingController.cs b/Management/Controllers/AdminReportingController.cs new file mode 100644 index 0000000..e13549b --- /dev/null +++ b/Management/Controllers/AdminReportingController.cs @@ -0,0 +1,67 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Admin endpoints for campaign performance reporting and intelligence. +/// Provides normalized metrics across all providers (Google, Meta, TikTok). +/// Requires Admin role. +/// +/// ENDPOINTS: +/// POST /api/admin/reporting/summary - KPI summary across all campaigns +/// POST /api/admin/reporting/campaigns - Per-campaign performance metrics +/// GET /api/admin/reporting/campaigns/{id} - Detailed metrics for one initiative +/// POST /api/admin/reporting/insights - Optimization recommendations +/// POST /api/admin/reporting/analysis - Post-campaign analysis data +/// +[ApiController] +[Route("api/admin/reporting")] +public sealed class AdminReportingController : AdminControllerBase +{ + public AdminReportingController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + /// + /// KPI summary: totals for spend, impressions, clicks, conversions, CTR, CPC, ROAS. + /// Body: { dateFrom?, dateTo?, clientId? } + /// + [HttpPost("summary")] + public Task Summary([FromBody] JsonElement body, CancellationToken ct) + => CallProc("spAdminReporting", "summary", body.ToString(), ct); + + /// + /// Per-campaign performance list with channel breakdowns. + /// Body: { status?, clientId?, dateFrom?, dateTo?, sortBy?, sortDir?, page?, pageSize? } + /// + [HttpPost("campaigns")] + public Task Campaigns([FromBody] JsonElement body, CancellationToken ct) + => CallProc("spAdminReporting", "campaigns", body.ToString(), ct); + + /// + /// Detailed metrics for a single initiative with daily time-series + /// and per-channel breakdowns. + /// + [HttpGet("campaigns/{initiativeId:long}")] + public Task CampaignDetail(long initiativeId, CancellationToken ct) + => CallProc("spAdminReporting", "detail", new { initiativeId }, ct); + + /// + /// Optimization insights and recommendations. + /// Body: { severity?, clientId? } + /// + [HttpPost("insights")] + public Task Insights([FromBody] JsonElement body, CancellationToken ct) + => CallProc("spAdminReporting", "insights", body.ToString(), ct); + + /// + /// Post-campaign analysis: completed campaigns with ROI, cost-efficiency, + /// and channel-level performance comparisons. + /// Body: { clientId?, dateFrom?, dateTo? } + /// + [HttpPost("analysis")] + public Task Analysis([FromBody] JsonElement body, CancellationToken ct) + => CallProc("spAdminReporting", "analysis", body.ToString(), ct); +} diff --git a/Management/Controllers/HelpController.cs b/Management/Controllers/HelpController.cs new file mode 100644 index 0000000..c070917 --- /dev/null +++ b/Management/Controllers/HelpController.cs @@ -0,0 +1,67 @@ +using Management.Data; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers; + +/// +/// Public help content endpoint — anonymous, no auth required. +/// Returns 200 with default message when key not found, so client +/// never needs to handle a 404. +/// +[ApiController] +[Route("api/help")] +public class HelpController : ControllerBase +{ + private readonly SqlService _sql; + private readonly ILogger _logger; + + public HelpController(SqlService sql, ILogger logger) + { + _sql = sql; + _logger = logger; + } + + /// + /// GET /api/help/{key} + /// Returns active help content for the given key, or a friendly + /// default if no content has been authored yet. + /// + [HttpGet("{key}")] + public async Task GetHelp(string key, CancellationToken ct) + { + try + { + var rqst = JsonSerializer.Serialize(new { helpKey = key }); + var resp = await _sql.ExecProcAsync("dbo.spHelp", "get", rqst, ct: ct); + + if (!string.IsNullOrWhiteSpace(resp)) + { + using var doc = JsonDocument.Parse(resp); + var root = doc.RootElement; + + if (root.TryGetProperty("ok", out var ok) && ok.GetBoolean()) + return Content(resp, "application/json"); + } + + // Key not found — return 200 with friendly default so clients + // don't need special 404 handling + return Ok(new + { + ok = true, + title = "Help", + body = "

No information available for this topic yet.

" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving help content for key: {Key}", key); + return Ok(new + { + ok = true, + title = "Help", + body = "

No information available for this topic yet.

" + }); + } + } +} diff --git a/Management/Controllers/MonitoringController.cs b/Management/Controllers/MonitoringController.cs index 80f474a..fb2641f 100644 --- a/Management/Controllers/MonitoringController.cs +++ b/Management/Controllers/MonitoringController.cs @@ -21,10 +21,13 @@ public sealed class MonitoringController : ControllerBase private readonly ClientContext _client; private readonly ILogger _log; - public MonitoringController(SqlService sql, ClientContext client, ILogger log) + private readonly Management.Services.GraphService _graph; + + public MonitoringController(SqlService sql, ClientContext client, Management.Services.GraphService graph, ILogger log) { _sql = sql; _client = client; + _graph = graph; _log = log; } @@ -53,10 +56,10 @@ public sealed class MonitoringController : ControllerBase /// /// Detailed system statistics. /// - [HttpGet("stats")] - public async Task Stats([FromQuery] int hours = 24, CancellationToken ct = default) + [HttpPost("stats")] + public async Task Stats([FromBody] JsonElement body, CancellationToken ct) { - var rqst = JsonSerializer.Serialize(new { hours }); + var rqst = body.ToString(); try { @@ -73,4 +76,47 @@ public sealed class MonitoringController : ControllerBase return StatusCode(500, new { ok = false, error = "Stats failed", detail = ex.Message }); } } + + /// + /// Staff user list — distinct staff who have ever performed an action, + /// derived directly from tbAdminActivity. + /// + [HttpGet("staff")] + public async Task Staff(CancellationToken ct) + { + try + { + var json = await _sql.ExecProcAsync("dbo.spAdminActivity", "distinct-staff", "{}", ct: ct); + return Content(json, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "[Monitoring] Staff list error"); + return StatusCode(500, new { ok = false, error = "Staff list failed", detail = ex.Message }); + } + } + + /// + /// Admin activity log — all mutating requests by staff members. + /// Accessible to Staff.Admin and Staff.Tech. + /// Body: { oid?, dateFrom?, dateTo?, page?, pageSize? } + /// + [HttpPost("activity")] + public async Task Activity([FromBody] JsonElement body, CancellationToken ct) + { + try + { + var resp = await _sql.ExecProcAsync("dbo.spAdminActivity", "list", body.ToString(), ct: ct); + + if (string.IsNullOrWhiteSpace(resp)) + return StatusCode(500, new { ok = false, error = "Service unavailable" }); + + return Content(resp, "application/json"); + } + catch (Exception ex) + { + _log.LogError(ex, "[Monitoring] Activity log error"); + return StatusCode(500, new { ok = false, error = "Activity log failed", detail = ex.Message }); + } + } } diff --git a/Management/Controllers/WeatherForecastController.cs b/Management/Controllers/WeatherForecastController.cs deleted file mode 100644 index 02a5169..0000000 --- a/Management/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Management.Controllers -{ - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase - { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } - } -} diff --git a/Management/Management.csproj b/Management/Management.csproj index cdcf535..4bc320c 100644 --- a/Management/Management.csproj +++ b/Management/Management.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -9,11 +9,13 @@ + - - - - + + + + + diff --git a/Management/Program.cs b/Management/Program.cs index bedbb67..08bc5e6 100644 --- a/Management/Program.cs +++ b/Management/Program.cs @@ -1,5 +1,6 @@ using Management.Data; using Management.Security; +using Management.Services; var builder = WebApplication.CreateBuilder(args); @@ -19,6 +20,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); +// Registration Function client (typed HttpClient) +builder.Services.AddHttpClient(); + +// Graph API service — app-only credentials for org tenant user listing +builder.Services.AddSingleton(); + var app = builder.Build(); // Middleware pipeline @@ -47,7 +54,10 @@ app.MapGet("/", () => Results.Ok(new { clients = new[] { "GET/POST /api/admin/clients", "GET/PUT/DELETE /api/admin/clients/{id}" }, users = new[] { "GET/POST /api/admin/users", "GET/PUT/DELETE /api/admin/users/{id}" }, - sessions = new[] { "GET /api/admin/sessions", "POST /api/admin/sessions/{id}/revoke" } + sessions = new[] { "GET /api/admin/sessions", "POST /api/admin/sessions/{id}/revoke" }, + templates = new[] { "GET/POST /api/admin/templates", "GET/PUT/DELETE /api/admin/templates/{id}", "GET /api/admin/templates/categories" }, + objectives = new[] { "GET/POST /api/admin/objectives", "GET/PUT/DELETE /api/admin/objectives/{id}" }, + reporting = new[] { "GET /api/admin/reporting/summary", "GET /api/admin/reporting/campaigns", "GET /api/admin/reporting/campaigns/{id}", "GET /api/admin/reporting/insights", "GET /api/admin/reporting/analysis" } } } })); @@ -55,6 +65,9 @@ app.MapGet("/", () => Results.Ok(new // Authentication middleware app.UseMiddleware(); +// Activity logging — fires after auth so ClientContext is populated +app.UseMiddleware(); + app.UseAuthorization(); app.MapControllers(); diff --git a/Management/SQL/spAdminClients.sql b/Management/SQL/spAdminClients.sql deleted file mode 100644 index 96d9f87..0000000 --- a/Management/SQL/spAdminClients.sql +++ /dev/null @@ -1,181 +0,0 @@ --- ============================================================ --- spAdminClients: Client (organization) management --- ============================================================ -CREATE OR ALTER PROCEDURE [dbo].[spAdminClients] - @action VARCHAR(50), - @rqst NVARCHAR(MAX), - @resp NVARCHAR(MAX) OUTPUT -AS -BEGIN - SET NOCOUNT ON; - - DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}'); - - ------------------------------------------------------------------------ - -- ACTION: create - ------------------------------------------------------------------------ - IF @action = 'create' - BEGIN - DECLARE @cName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), ''); - - IF @cName IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"clientName is required"}'; - RETURN; - END - - IF EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltName = @cName) - BEGIN - SET @resp = N'{"ok":false,"error":"Client name already exists"}'; - RETURN; - END - - DECLARE @cId UNIQUEIDENTIFIER = NEWID(); - INSERT INTO dbo.tbClient (cltId, cltName, cltStatus) - VALUES (@cId, @cName, 'Active'); - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - @cId AS clientId, - @cName AS clientName, - 'Active' AS status - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: get - ------------------------------------------------------------------------ - IF @action = 'get' - BEGIN - DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId')); - - IF @gId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"clientId is required"}'; - RETURN; - END - - IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @gId) - BEGIN - SET @resp = N'{"ok":false,"error":"Client not found"}'; - RETURN; - END - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - c.cltId AS clientId, - c.cltName AS clientName, - c.cltStatus AS status, - c.cltCreatedUtc AS createdAt, - (SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount, - (SELECT COUNT(*) FROM dbo.tbAdAccount WHERE accCltId = c.cltId) AS accountCount - FROM dbo.tbClient c WHERE c.cltId = @gId - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: list - ------------------------------------------------------------------------ - IF @action = 'list' - BEGIN - DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), ''); - DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1); - DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50); - - DECLARE @clients NVARCHAR(MAX); - SELECT @clients = ( - SELECT - c.cltId AS clientId, - c.cltName AS clientName, - c.cltStatus AS status, - c.cltCreatedUtc AS createdAt, - (SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount - FROM dbo.tbClient c - WHERE @lStatus IS NULL OR c.cltStatus = @lStatus - ORDER BY c.cltName - OFFSET (@lPage - 1) * @lPageSize ROWS - FETCH NEXT @lPageSize ROWS ONLY - FOR JSON PATH - ); - - DECLARE @lTotal INT; - SELECT @lTotal = COUNT(*) FROM dbo.tbClient WHERE @lStatus IS NULL OR cltStatus = @lStatus; - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - JSON_QUERY(ISNULL(@clients, '[]')) AS clients, - @lTotal AS totalCount, - @lPage AS page, - @lPageSize AS pageSize - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: update - ------------------------------------------------------------------------ - IF @action = 'update' - BEGIN - DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId')); - DECLARE @uName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), ''); - DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), ''); - - IF @uId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"clientId is required"}'; - RETURN; - END - - IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @uId) - BEGIN - SET @resp = N'{"ok":false,"error":"Client not found"}'; - RETURN; - END - - UPDATE dbo.tbClient - SET cltName = ISNULL(@uName, cltName), - cltStatus = ISNULL(@uStatus, cltStatus) - WHERE cltId = @uId; - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - cltId AS clientId, - cltName AS clientName, - cltStatus AS status - FROM dbo.tbClient WHERE cltId = @uId - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: delete (soft delete) - ------------------------------------------------------------------------ - IF @action = 'delete' - BEGIN - DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId')); - - IF @dId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"clientId is required"}'; - RETURN; - END - - UPDATE dbo.tbClient SET cltStatus = 'Inactive' WHERE cltId = @dId; - - SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER); - RETURN; - END - - SET @resp = N'{"ok":false,"error":"Unknown action"}'; -END -GO diff --git a/Management/SQL/spAdminSessions.sql b/Management/SQL/spAdminSessions.sql deleted file mode 100644 index 2ace4e4..0000000 --- a/Management/SQL/spAdminSessions.sql +++ /dev/null @@ -1,111 +0,0 @@ --- ============================================================ --- spAdminSessions: Session management --- ============================================================ -CREATE OR ALTER PROCEDURE [dbo].[spAdminSessions] - @action VARCHAR(50), - @rqst NVARCHAR(MAX), - @resp NVARCHAR(MAX) OUTPUT -AS -BEGIN - SET NOCOUNT ON; - - DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}'); - - ------------------------------------------------------------------------ - -- ACTION: list - ------------------------------------------------------------------------ - IF @action = 'list' - BEGIN - DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId')); - DECLARE @lUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId')); - DECLARE @lActiveOnly BIT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.activeOnly') AS BIT), 1); - DECLARE @lLimit INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.limit') AS INT), 100); - - DECLARE @sessions NVARCHAR(MAX); - SELECT @sessions = ( - SELECT TOP (@lLimit) - s.sesId AS sessionId, - u.usrId AS userId, - u.usrEmail AS userEmail, - u.usrDisplayName AS displayName, - c.cltId AS clientId, - c.cltName AS clientName, - s.sesCreatedUtc AS createdAt, - s.sesExpiresUtc AS expiresAt, - s.sesLastActivityUtc AS lastActivity, - s.sesIpAddress AS ipAddress, - s.sesIsRevoked AS isRevoked - FROM dbo.tbSession s - JOIN dbo.tbUser u ON u.usrId = s.sesUsrId - JOIN dbo.tbClient c ON c.cltId = s.sesCltId - WHERE (@lClientId IS NULL OR c.cltId = @lClientId) - AND (@lUserId IS NULL OR u.usrId = @lUserId) - AND (@lActiveOnly = 0 OR (s.sesIsRevoked = 0 AND s.sesExpiresUtc > SYSUTCDATETIME())) - ORDER BY s.sesLastActivityUtc DESC - FOR JSON PATH - ); - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - JSON_QUERY(ISNULL(@sessions, '[]')) AS sessions - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: revoke - ------------------------------------------------------------------------ - IF @action = 'revoke' - BEGIN - DECLARE @rSessionId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.sessionId')); - - IF @rSessionId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"sessionId is required"}'; - RETURN; - END - - UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesId = @rSessionId; - - SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: revokeAllForUser - ------------------------------------------------------------------------ - IF @action = 'revokeAllForUser' - BEGIN - DECLARE @raUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId')); - - IF @raUserId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"userId is required"}'; - RETURN; - END - - UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesUsrId = @raUserId; - - SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: cleanup - ------------------------------------------------------------------------ - IF @action = 'cleanup' - BEGIN - DECLARE @daysOld INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.daysOld') AS INT), 30); - - DELETE FROM dbo.tbSession - WHERE sesExpiresUtc < DATEADD(DAY, -@daysOld, SYSUTCDATETIME()); - - SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsDeleted FOR JSON PATH, WITHOUT_ARRAY_WRAPPER); - RETURN; - END - - SET @resp = N'{"ok":false,"error":"Unknown action"}'; -END -GO diff --git a/Management/SQL/spAdminUsers.sql b/Management/SQL/spAdminUsers.sql deleted file mode 100644 index 3a2bebb..0000000 --- a/Management/SQL/spAdminUsers.sql +++ /dev/null @@ -1,288 +0,0 @@ --- ============================================================ --- spAdminUsers: User management --- ============================================================ -CREATE OR ALTER PROCEDURE [dbo].[spAdminUsers] - @action VARCHAR(50), - @rqst NVARCHAR(MAX), - @resp NVARCHAR(MAX) OUTPUT -AS -BEGIN - SET NOCOUNT ON; - - DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}'); - - ------------------------------------------------------------------------ - -- ACTION: create - ------------------------------------------------------------------------ - IF @action = 'create' - BEGIN - DECLARE @cEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), ''); - DECLARE @cDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), ''); - DECLARE @cClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId')); - DECLARE @cRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User'); - - IF @cEmail IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"email is required"}'; - RETURN; - END - - IF EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrEmail = @cEmail) - BEGIN - SET @resp = N'{"ok":false,"error":"User with this email already exists"}'; - RETURN; - END - - IF @cClientId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @cClientId) - BEGIN - SET @resp = N'{"ok":false,"error":"Client not found"}'; - RETURN; - END - - DECLARE @cUserId UNIQUEIDENTIFIER = NEWID(); - DECLARE @cEntraSub NVARCHAR(100) = 'pending-' + CAST(@cUserId AS NVARCHAR(50)); - - INSERT INTO dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus) - VALUES (@cUserId, @cEntraSub, 'Pending', @cEntraSub, @cEmail, @cDisplayName, 'Active'); - - IF @cClientId IS NOT NULL - BEGIN - INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole) - VALUES (@cUserId, @cClientId, @cRole); - END - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - @cUserId AS userId, - @cEmail AS email, - @cDisplayName AS displayName, - @cClientId AS clientId, - @cRole AS [role] - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: get - ------------------------------------------------------------------------ - IF @action = 'get' - BEGIN - DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId')); - - IF @gId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"userId is required"}'; - RETURN; - END - - IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @gId) - BEGIN - SET @resp = N'{"ok":false,"error":"User not found"}'; - RETURN; - END - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - u.usrId AS userId, - u.usrEmail AS email, - u.usrDisplayName AS displayName, - u.usrStatus AS status, - u.usrCreatedUtc AS createdAt, - ( - SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role] - FROM dbo.tbUserClientRole r - JOIN dbo.tbClient c ON c.cltId = r.ucrCltId - WHERE r.ucrUsrId = u.usrId - FOR JSON PATH - ) AS clients - FROM dbo.tbUser u WHERE u.usrId = @gId - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: list - ------------------------------------------------------------------------ - IF @action = 'list' - BEGIN - DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), ''); - DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId')); - DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1); - DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50); - - DECLARE @users NVARCHAR(MAX); - SELECT @users = ( - SELECT - u.usrId AS userId, - u.usrEmail AS email, - u.usrDisplayName AS displayName, - u.usrStatus AS status, - u.usrCreatedUtc AS createdAt, - ( - SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role] - FROM dbo.tbUserClientRole r - JOIN dbo.tbClient c ON c.cltId = r.ucrCltId - WHERE r.ucrUsrId = u.usrId - FOR JSON PATH - ) AS clients - FROM dbo.tbUser u - WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus) - AND (@lClientId IS NULL OR EXISTS ( - SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId - )) - ORDER BY u.usrEmail - OFFSET (@lPage - 1) * @lPageSize ROWS - FETCH NEXT @lPageSize ROWS ONLY - FOR JSON PATH - ); - - DECLARE @lTotal INT; - SELECT @lTotal = COUNT(*) - FROM dbo.tbUser u - WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus) - AND (@lClientId IS NULL OR EXISTS ( - SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId - )); - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - JSON_QUERY(ISNULL(@users, '[]')) AS users, - @lTotal AS totalCount, - @lPage AS page, - @lPageSize AS pageSize - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: update - ------------------------------------------------------------------------ - IF @action = 'update' - BEGIN - DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId')); - DECLARE @uDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), ''); - DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), ''); - - IF @uId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"userId is required"}'; - RETURN; - END - - IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @uId) - BEGIN - SET @resp = N'{"ok":false,"error":"User not found"}'; - RETURN; - END - - UPDATE dbo.tbUser - SET usrDisplayName = ISNULL(@uDisplayName, usrDisplayName), - usrStatus = ISNULL(@uStatus, usrStatus) - WHERE usrId = @uId; - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - usrId AS userId, - usrEmail AS email, - usrDisplayName AS displayName, - usrStatus AS status - FROM dbo.tbUser WHERE usrId = @uId - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: delete (soft delete) - ------------------------------------------------------------------------ - IF @action = 'delete' - BEGIN - DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId')); - - IF @dId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"userId is required"}'; - RETURN; - END - - UPDATE dbo.tbUser SET usrStatus = 'Inactive' WHERE usrId = @dId; - - SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: linkToClient - ------------------------------------------------------------------------ - IF @action = 'linkToClient' - BEGIN - DECLARE @luUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId')); - DECLARE @luClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId')); - DECLARE @luRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User'); - - IF @luUserId IS NULL OR @luClientId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"userId and clientId are required"}'; - RETURN; - END - - IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @luUserId) - BEGIN - SET @resp = N'{"ok":false,"error":"User not found"}'; - RETURN; - END - - IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @luClientId) - BEGIN - SET @resp = N'{"ok":false,"error":"Client not found"}'; - RETURN; - END - - IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId) - BEGIN - UPDATE dbo.tbUserClientRole - SET ucrRole = @luRole - WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId; - - SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'updated' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER); - RETURN; - END - - INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole) - VALUES (@luUserId, @luClientId, @luRole); - - SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'created' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: unlinkFromClient - ------------------------------------------------------------------------ - IF @action = 'unlinkFromClient' - BEGIN - DECLARE @ruUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId')); - DECLARE @ruClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId')); - - IF @ruUserId IS NULL OR @ruClientId IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"userId and clientId are required"}'; - RETURN; - END - - DELETE FROM dbo.tbUserClientRole - WHERE ucrUsrId = @ruUserId AND ucrCltId = @ruClientId; - - SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER); - RETURN; - END - - SET @resp = N'{"ok":false,"error":"Unknown action"}'; -END -GO diff --git a/Management/SQL/spMonitoring.sql b/Management/SQL/spMonitoring.sql deleted file mode 100644 index 27f174b..0000000 --- a/Management/SQL/spMonitoring.sql +++ /dev/null @@ -1,106 +0,0 @@ --- ============================================================ --- spMonitoring: System health and statistics --- ============================================================ -CREATE OR ALTER PROCEDURE [dbo].[spMonitoring] - @action VARCHAR(50), - @rqst NVARCHAR(MAX), - @resp NVARCHAR(MAX) OUTPUT -AS -BEGIN - SET NOCOUNT ON; - - DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}'); - - ------------------------------------------------------------------------ - -- ACTION: health - -- System health overview - ------------------------------------------------------------------------ - IF @action = 'health' - BEGIN - DECLARE @clientCount INT, @userCount INT, @sessionCount INT, @logCount24h INT; - - SELECT @clientCount = COUNT(*) FROM dbo.tbClient WHERE cltStatus = 'Active'; - SELECT @userCount = COUNT(*) FROM dbo.tbUser WHERE usrStatus = 'Active'; - SELECT @sessionCount = COUNT(*) FROM dbo.tbSession WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME(); - - -- Check if tbAdpApiLog exists (may not be in all installations) - IF OBJECT_ID('dbo.tbAdpApiLog', 'U') IS NOT NULL - EXEC sp_executesql N'SELECT @cnt = COUNT(*) FROM dbo.tbAdpApiLog WHERE createdUtc > DATEADD(HOUR, -24, SYSUTCDATETIME())', - N'@cnt INT OUTPUT', @cnt = @logCount24h OUTPUT; - ELSE - SET @logCount24h = 0; - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - @clientCount AS activeClients, - @userCount AS activeUsers, - @sessionCount AS activeSessions, - @logCount24h AS apiCalls24h, - SYSUTCDATETIME() AS serverTimeUtc - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: stats - -- Detailed statistics - ------------------------------------------------------------------------ - IF @action = 'stats' - BEGIN - DECLARE @hours INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.hours') AS INT), 24); - - -- Clients by status - DECLARE @clientsByStatus NVARCHAR(MAX); - SELECT @clientsByStatus = ( - SELECT cltStatus AS status, COUNT(*) AS [count] - FROM dbo.tbClient - GROUP BY cltStatus - FOR JSON PATH - ); - - -- Users by status - DECLARE @usersByStatus NVARCHAR(MAX); - SELECT @usersByStatus = ( - SELECT usrStatus AS status, COUNT(*) AS [count] - FROM dbo.tbUser - GROUP BY usrStatus - FOR JSON PATH - ); - - -- Sessions stats - DECLARE @activeSessions INT, @expiredSessions INT, @revokedSessions INT; - SELECT @activeSessions = COUNT(*) FROM dbo.tbSession - WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME(); - SELECT @expiredSessions = COUNT(*) FROM dbo.tbSession - WHERE sesIsRevoked = 0 AND sesExpiresUtc <= SYSUTCDATETIME(); - SELECT @revokedSessions = COUNT(*) FROM dbo.tbSession - WHERE sesIsRevoked = 1; - - -- Recent registrations (last 7 days) - DECLARE @recentClients INT, @recentUsers INT; - SELECT @recentClients = COUNT(*) FROM dbo.tbClient - WHERE cltCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME()); - SELECT @recentUsers = COUNT(*) FROM dbo.tbUser - WHERE usrCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME()); - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - JSON_QUERY(ISNULL(@clientsByStatus, '[]')) AS clientsByStatus, - JSON_QUERY(ISNULL(@usersByStatus, '[]')) AS usersByStatus, - @activeSessions AS activeSessions, - @expiredSessions AS expiredSessions, - @revokedSessions AS revokedSessions, - @recentClients AS newClientsLast7Days, - @recentUsers AS newUsersLast7Days, - SYSUTCDATETIME() AS serverTimeUtc - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - SET @resp = N'{"ok":false,"error":"Unknown action"}'; -END -GO diff --git a/Management/SQL/spOnboarding.sql b/Management/SQL/spOnboarding.sql deleted file mode 100644 index 0faef06..0000000 --- a/Management/SQL/spOnboarding.sql +++ /dev/null @@ -1,151 +0,0 @@ --- ============================================================ --- spOnboarding: User/Client registration --- ============================================================ -CREATE OR ALTER PROCEDURE [dbo].[spOnboarding] - @action VARCHAR(50), - @rqst NVARCHAR(MAX), - @resp NVARCHAR(MAX) OUTPUT -AS -BEGIN - SET NOCOUNT ON; - - DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}'); - - ------------------------------------------------------------------------ - -- ACTION: status - -- Check if user is registered and has client access - ------------------------------------------------------------------------ - IF @action = 'status' - BEGIN - DECLARE @sSubject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), ''); - DECLARE @sEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), ''); - - DECLARE @sUserId UNIQUEIDENTIFIER; - DECLARE @sUserEmail NVARCHAR(256); - - SELECT @sUserId = usrId, @sUserEmail = usrEmail - FROM dbo.tbUser - WHERE usrEntraSub = @sSubject; - - -- User doesn't exist - IF @sUserId IS NULL - BEGIN - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - CAST(0 AS BIT) AS isRegistered, - @sEmail AS email - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - -- Check for client access - DECLARE @clients NVARCHAR(MAX); - SELECT @clients = ( - SELECT - c.cltId AS clientId, - c.cltName AS clientName, - r.ucrRole AS [role] - FROM dbo.tbUserClientRole r - JOIN dbo.tbClient c ON c.cltId = r.ucrCltId AND c.cltStatus = 'Active' - WHERE r.ucrUsrId = @sUserId - FOR JSON PATH - ); - - IF @clients IS NULL OR @clients = '[]' - BEGIN - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - CAST(0 AS BIT) AS isRegistered, - @sUserId AS userId, - @sUserEmail AS email - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - CAST(1 AS BIT) AS isRegistered, - @sUserId AS userId, - @sUserEmail AS email, - JSON_QUERY(@clients) AS clients - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - ------------------------------------------------------------------------ - -- ACTION: register - -- Creates client + links user as Admin - ------------------------------------------------------------------------ - IF @action = 'register' - BEGIN - DECLARE @provider VARCHAR(30) = NULLIF(JSON_VALUE(@j, '$.provider'), ''); - DECLARE @subject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), ''); - DECLARE @email NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), ''); - DECLARE @displayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), ''); - DECLARE @clientName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), ''); - - -- Validation - IF @provider IS NULL OR @subject IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"provider and subject are required"}'; - RETURN; - END - - IF @clientName IS NULL - BEGIN - SET @resp = N'{"ok":false,"error":"clientName is required"}'; - RETURN; - END - - -- Find or create user - DECLARE @userId UNIQUEIDENTIFIER; - - SELECT @userId = usrId - FROM dbo.tbUser - WHERE usrEntraSub = @subject; - - IF @userId IS NULL - BEGIN - SET @userId = NEWID(); - INSERT dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus) - VALUES (@userId, @subject, @provider, @subject, @email, @displayName, 'Active'); - END - - -- Check if user already has client access - IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @userId) - BEGIN - SET @resp = N'{"ok":false,"error":"User is already registered"}'; - RETURN; - END - - -- Create client - DECLARE @clientId UNIQUEIDENTIFIER = NEWID(); - INSERT dbo.tbClient (cltId, cltName, cltStatus) - VALUES (@clientId, @clientName, 'Active'); - - -- Link user as Admin - INSERT dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole) - VALUES (@userId, @clientId, 'Admin'); - - -- Return success - SET @resp = ( - SELECT - CAST(1 AS BIT) AS ok, - @userId AS userId, - @clientId AS clientId, - @clientName AS clientName, - 'Admin' AS [role] - FOR JSON PATH, WITHOUT_ARRAY_WRAPPER - ); - RETURN; - END - - SET @resp = N'{"ok":false,"error":"Unknown action"}'; -END -GO diff --git a/Management/Security/ActivityLoggingMiddleware.cs b/Management/Security/ActivityLoggingMiddleware.cs new file mode 100644 index 0000000..99441cc --- /dev/null +++ b/Management/Security/ActivityLoggingMiddleware.cs @@ -0,0 +1,119 @@ +using Management.Data; +using Management.Security; +using System.Text.Json; + +namespace Management.Security; + +/// +/// Logs all mutating (non-GET) requests to tbAdminActivity when a staff JWT is present. +/// +/// Enables request body buffering so a compact filter summary can be extracted +/// after the controller has run. Fire-and-forget — never delays the response. +/// Registered after ClientAuthMiddleware so ClientContext is populated. +/// +public sealed class ActivityLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + private static readonly string[] _skipPrefixes = + { + "/api/help", "/api/test", "/swagger", "/health" + }; + + // Body keys treated as filter context worth surfacing in the activity log. + // Pagination keys (page, pageSize) are intentionally excluded. + private static readonly string[] _filterKeys = + [ + "search", "status", "clientId", "category", "network", + "dateFrom", "dateTo", "action", "ruleId", "initiativeId", + ]; + + public ActivityLoggingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql) + { + var method = context.Request.Method; + var isRead = HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method); + + // Buffer request body before calling next so we can re-read it after + if (!isRead) + context.Request.EnableBuffering(); + + await _next(context); + + if (isRead) return; + + var oid = clientContext.ClientId; + if (string.IsNullOrWhiteSpace(oid) || clientContext.IsDevBypass) return; + + var path = context.Request.Path.Value ?? ""; + if (_skipPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) return; + + var statusCode = context.Response.StatusCode; + var filterSummary = await ReadFilterSummaryAsync(context.Request); + + _ = Task.Run(async () => + { + try + { + var rqst = JsonSerializer.Serialize(new + { + oid, + email = clientContext.Email, + displayName = clientContext.ClientName, + method, + path, + statusCode, + filter = filterSummary, // null for bodies with no recognised filter keys + }); + + await sql.ExecProcAsync("dbo.spAdminActivity", "log", rqst); + } + catch (Exception ex) + { + _logger.LogWarning("[Activity] Log failed: {Message}", ex.Message); + } + }); + } + + private async Task ReadFilterSummaryAsync(HttpRequest request) + { + if (request.ContentLength is null or 0) return null; + if (request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) != true) return null; + + try + { + request.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) return null; + + using var doc = JsonDocument.Parse(body); + var parts = new List(); + + foreach (var key in _filterKeys) + { + if (!doc.RootElement.TryGetProperty(key, out var val)) continue; + if (val.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) continue; + + var str = val.ValueKind == JsonValueKind.String + ? val.GetString() + : val.ToString(); + + if (!string.IsNullOrWhiteSpace(str)) + parts.Add($"{key}={str}"); + } + + return parts.Count > 0 ? string.Join(" ", parts) : null; + } + catch + { + return null; + } + } +} diff --git a/Management/Security/ClientAuthMiddleware.cs b/Management/Security/ClientAuthMiddleware.cs index 42b9f87..14aa53a 100644 --- a/Management/Security/ClientAuthMiddleware.cs +++ b/Management/Security/ClientAuthMiddleware.cs @@ -12,10 +12,18 @@ namespace Management.Security; /// Authentication middleware for Management API. /// /// Auth paths: -/// - /api/onboarding/* → JWT (user may not have session yet) -/// - /api/admin/* → Session + Admin role -/// - /api/monitoring/* → Session + Admin role -/// - /api/test/* → Anonymous +/// - /api/onboarding/* → JWT (Entra, any staff role) +/// - /api/monitoring/* → JWT (Entra, Staff.Admin or Staff.Tech role) +/// - /api/admin/* → JWT (Entra, Staff.Admin role only) +/// - /api/registration/*→ JWT (Entra, Staff.Admin role only) +/// - /api/staff/* → JWT (Entra, Staff.Admin role only) +/// - /api/test/* → Anonymous +/// - /api/documents/* → JWT (Entra, Staff.Admin or Staff.Tech role) +/// - /api/help/* → Anonymous +/// +/// App Role values (defined in Entra portal on the staff app registration): +/// Staff.Admin → full platform access +/// Staff.Tech → monitoring/health only /// public sealed class ClientAuthMiddleware { @@ -28,9 +36,10 @@ public sealed class ClientAuthMiddleware "/", "/health" }; - private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" }; + private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test", "/api/help" }; private static readonly string[] _jwtOnlyPrefixes = { "/api/onboarding" }; - private static readonly string[] _adminRequiredPrefixes = { "/api/monitoring", "/api/admin" }; + private static readonly string[] _staffRequiredPrefixes = { "/api/monitoring", "/api/documents" }; // Admin or Tech + private static readonly string[] _adminRequiredPrefixes = { "/api/admin", "/api/registration", "/api/staff" }; // Admin only private static ConfigurationManager? _oidcConfigManager; private static readonly object _oidcLock = new(); @@ -75,10 +84,36 @@ public sealed class ClientAuthMiddleware return; } - // Admin-required paths + // Staff-required paths (Admin or Tech role) — monitoring/health + // Try session auth (client session tokens for CIAM users). + // then fall back to direct JWT for service-to-service calls. + if (IsStaffRequiredPath(path)) + { + var staffAuthed = await TrySessionAuthAsync(context, clientContext, sql) + || await TryJwtAuthAsync(context, clientContext); + if (staffAuthed) + { + if (!clientContext.IsStaff) + { + context.Response.StatusCode = 403; + await context.Response.WriteAsJsonAsync(new { ok = false, error = "Staff access required" }); + return; + } + await _next(context); + return; + } + + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid staff authentication required" }); + return; + } + + // Admin-required paths (Admin role only) if (IsAdminRequiredPath(path)) { - if (await TrySessionAuthAsync(context, clientContext, sql)) + var adminAuthed = await TrySessionAuthAsync(context, clientContext, sql) + || await TryJwtAuthAsync(context, clientContext); + if (adminAuthed) { if (!clientContext.IsAdmin) { @@ -86,13 +121,12 @@ public sealed class ClientAuthMiddleware await context.Response.WriteAsJsonAsync(new { ok = false, error = "Admin access required" }); return; } - await _next(context); return; } context.Response.StatusCode = 401; - await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin session required" }); + await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin authentication required" }); return; } @@ -113,6 +147,9 @@ public sealed class ClientAuthMiddleware private static bool IsJwtOnlyPath(string path) => _jwtOnlyPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)); + private static bool IsStaffRequiredPath(string path) => + _staffRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)); + private static bool IsAdminRequiredPath(string path) => _adminRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)); @@ -164,8 +201,9 @@ public sealed class ClientAuthMiddleware try { - var rqst = JsonSerializer.Serialize(new { sessionToken = token }); - var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted); + var rqst = JsonSerializer.Serialize(new { sessionToken = token }); + var validateProc = "dbo.spClientSession"; // Staff use JWT Bearer; only client sessions exist + var resp = await sql.ExecProcAsync(validateProc, "validate", rqst, ct: context.RequestAborted); if (string.IsNullOrWhiteSpace(resp)) return false; @@ -178,12 +216,20 @@ public sealed class ClientAuthMiddleware var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root; clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null; - clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null; - clientContext.PlatformClientId = clientContext.ClientId; + + // Admin sessions return adminId; client sessions return clientId — handle both + var clientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null; + if (string.IsNullOrWhiteSpace(clientId)) + clientId = data.TryGetProperty("adminId", out var aid) ? aid.GetString() : null; + clientContext.ClientId = clientId; + clientContext.PlatformClientId = clientId; + clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null; - clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null; - clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null; - clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null; + clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null; + clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null; + clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null; + + // IsStaff is computed from Role in ClientContext — no assignment needed return clientContext.IsAuthenticated; } @@ -207,9 +253,9 @@ public sealed class ClientAuthMiddleware if (string.IsNullOrWhiteSpace(token)) return false; - var tenantId = _config["Auth:EntraId:TenantId"]; - var clientId = _config["Auth:EntraId:ClientId"]; - var instance = _config["Auth:EntraId:Instance"] ?? "https://login.microsoftonline.com/"; + var tenantId = _config["Auth:Staff:TenantId"]; + var clientId = _config["Auth:Staff:ClientId"]; + var instance = _config["Auth:Staff:Instance"] ?? "https://usimclients.ciamlogin.com/"; if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId)) return false; @@ -217,28 +263,44 @@ public sealed class ClientAuthMiddleware try { var handler = new JwtSecurityTokenHandler(); - var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0"; + // Disable default claim type remapping so JWT claim names (roles, oid, etc.) + // are preserved as-is. Without this, "roles" is remapped to ClaimTypes.Role + // and FindAll("roles") returns empty — causing IsAdmin to be false. + handler.InboundClaimTypeMap.Clear(); + + var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0"; var metadataAddress = $"{authority}/.well-known/openid-configuration"; - var mgr = GetOrCreateConfigManager(metadataAddress); + var mgr = GetOrCreateConfigManager(metadataAddress); var openIdConfig = await mgr.GetConfigurationAsync(context.RequestAborted); var validationParams = new TokenValidationParameters { - ValidateIssuer = true, - ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" }, - ValidateAudience = true, - ValidAudiences = new[] { clientId, $"api://{clientId}" }, - ValidateLifetime = true, + ValidateIssuer = true, + ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" }, + ValidateAudience = true, + ValidAudiences = new[] { clientId, $"api://{clientId}" }, + ValidateLifetime = true, IssuerSigningKeys = openIdConfig.SigningKeys, - ClockSkew = TimeSpan.FromMinutes(5) + ClockSkew = TimeSpan.FromMinutes(5) }; var principal = handler.ValidateToken(token, validationParams, out _); - clientContext.ClientId = principal.FindFirstValue("oid") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); - clientContext.Email = principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue(ClaimTypes.Email); - clientContext.ClientName = principal.FindFirstValue("name") ?? principal.FindFirstValue(ClaimTypes.Name); + // Map Entra App Role values to internal role names. + // Users with no recognized role get null — middleware rejects them. + var roles = principal.FindAll("roles").Select(c => c.Value).ToList(); + var role = roles.Contains("Staff.Admin") ? "Admin" + : roles.Contains("Staff.Tech") ? "Tech" + : null; // no valid role assigned — reject + + clientContext.ClientId = principal.FindFirstValue("oid") + ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); + clientContext.Email = principal.FindFirstValue("preferred_username") + ?? principal.FindFirstValue(ClaimTypes.Email); + clientContext.ClientName = principal.FindFirstValue("name") + ?? principal.FindFirstValue(ClaimTypes.Name); + clientContext.Role = role; return clientContext.IsAuthenticated; } diff --git a/Management/Security/ClientContext.cs b/Management/Security/ClientContext.cs index 5efa023..7c497bd 100644 --- a/Management/Security/ClientContext.cs +++ b/Management/Security/ClientContext.cs @@ -16,5 +16,17 @@ public sealed class ClientContext public bool IsDevBypass { get; set; } public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId); - public bool IsAdmin => string.Equals(Role, "Admin", StringComparison.OrdinalIgnoreCase); + + /// Full platform access. + /// Full admin access — SuperAdmin or Admin role. + public bool IsAdmin => + string.Equals(Role, "SuperAdmin", StringComparison.OrdinalIgnoreCase) || + string.Equals(Role, "Admin", StringComparison.OrdinalIgnoreCase); + + /// Health monitoring and Tech Client access only. + public bool IsTech => + string.Equals(Role, "Tech", StringComparison.OrdinalIgnoreCase); + + /// Any authenticated staff member (SuperAdmin, Admin or Tech). + public bool IsStaff => IsAdmin || IsTech; } diff --git a/Management/Services/GraphService.cs b/Management/Services/GraphService.cs new file mode 100644 index 0000000..2744a38 --- /dev/null +++ b/Management/Services/GraphService.cs @@ -0,0 +1,36 @@ +using Azure.Identity; +using Microsoft.Graph; + +namespace Management.Services; + +/// +/// Wraps a Microsoft.Graph client authenticated with app-only (client credentials) +/// credentials against the org tenant. +/// +/// Registered as a singleton in Program.cs — one GraphServiceClient per process. +/// +public sealed class GraphService +{ + private readonly GraphServiceClient _client; + private readonly ILogger _log; + + public GraphService(IConfiguration config, ILogger log) + { + _log = log; + + var tenantId = config["Graph:TenantId"] ?? ""; + var clientId = config["Graph:ClientId"] ?? ""; + var clientSecret = config["Graph:ClientSecret"] ?? ""; + + if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) + { + _log.LogWarning("[Graph] One or more Graph config values are missing (TenantId, ClientId, ClientSecret). " + + "GET /api/admin/access/users will return an error until these are set."); + } + + var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); + _client = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); + } + + public GraphServiceClient Client => _client; +} diff --git a/Management/Services/RegistrationClient.cs b/Management/Services/RegistrationClient.cs new file mode 100644 index 0000000..4a79464 --- /dev/null +++ b/Management/Services/RegistrationClient.cs @@ -0,0 +1,150 @@ +using System.Text.Json; + +namespace Management.Services; + +/// +/// HTTP client for calling the Registration Azure Function. +/// +/// Configuration (appsettings.json): +/// "Registration": { +/// "BaseUrl": "https://your-function-app.azurewebsites.net/api", +/// "FunctionKey": "your-function-key-here" +/// } +/// +/// Registered in DI as a typed HttpClient. +/// +public class RegistrationClient +{ + private readonly HttpClient _http; + private readonly ILogger _log; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public RegistrationClient(HttpClient http, IConfiguration config, ILogger log) + { + _http = http; + _log = log; + + var baseUrl = config["Registration:BaseUrl"]; + var functionKey = config["Registration:FunctionKey"]; + + if (!string.IsNullOrWhiteSpace(baseUrl)) + { + try + { + _http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + } + catch (UriFormatException ex) + { + log.LogWarning(ex, "[RegistrationClient] Invalid BaseUrl: {BaseUrl}", baseUrl); + } + } + else + { + log.LogWarning("[RegistrationClient] Registration:BaseUrl not configured — registration proxy disabled"); + } + + // Function key sent as query param (Azure Functions default auth) + if (!string.IsNullOrWhiteSpace(functionKey)) + { + // Store key for per-request query string injection + _functionKey = functionKey; + } + + _log.LogInformation("[RegistrationClient] Configured. BaseUrl={BaseUrl} KeyPresent={HasKey}", + _http.BaseAddress, !string.IsNullOrWhiteSpace(functionKey)); + } + + private readonly string? _functionKey; + + // ── API Methods ── + + public async Task GetPendingAsync(CancellationToken ct) + { + return await GetAsync("registration/pending", ct); + } + + public async Task GetByIdAsync(string registrationId, CancellationToken ct) + { + return await GetAsync($"registration/item/{registrationId}", ct); + } + + public async Task RejectAsync(string registrationId, string? reason, CancellationToken ct) + { + return await PostAsync($"registration/action/{registrationId}/reject", new { reason }, ct); + } + + public async Task CompleteAsync(string registrationId, string? platformClientId, CancellationToken ct) + { + return await PostAsync($"registration/action/{registrationId}/complete", new { platformClientId }, ct); + } + + // ── Internal HTTP helpers ── + + private async Task GetAsync(string path, CancellationToken ct) + { + try + { + var url = AppendKey(path); + _log.LogInformation("[RegistrationClient] GET {Path}", path); + + var response = await _http.GetAsync(url, ct); + var body = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + _log.LogWarning("[RegistrationClient] GET {Path} → {Status}: {Body}", + path, (int)response.StatusCode, body[..Math.Min(200, body.Length)]); + return null; + } + + return JsonDocument.Parse(body); + } + catch (Exception ex) + { + _log.LogError(ex, "[RegistrationClient] GET {Path} failed", path); + return null; + } + } + + private async Task PostAsync(string path, object? payload, CancellationToken ct) + { + try + { + var url = AppendKey(path); + var json = payload != null ? JsonSerializer.Serialize(payload, JsonOpts) : "{}"; + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + _log.LogInformation("[RegistrationClient] POST {Path}", path); + + var response = await _http.PostAsync(url, content, ct); + var body = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + _log.LogWarning("[RegistrationClient] POST {Path} → {Status}: {Body}", + path, (int)response.StatusCode, body[..Math.Min(200, body.Length)]); + } + + return JsonDocument.Parse(body); + } + catch (Exception ex) + { + _log.LogError(ex, "[RegistrationClient] POST {Path} failed", path); + return null; + } + } + + private string AppendKey(string path) + { + if (string.IsNullOrWhiteSpace(_functionKey)) + return path; + + var separator = path.Contains('?') ? '&' : '?'; + return $"{path}{separator}code={_functionKey}"; + } +} diff --git a/Management/WeatherForecast.cs b/Management/WeatherForecast.cs deleted file mode 100644 index dc323e3..0000000 --- a/Management/WeatherForecast.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Management -{ - public class WeatherForecast - { - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } - } -} diff --git a/Management/appsettings.json b/Management/appsettings.json index bec8785..c9252aa 100644 --- a/Management/appsettings.json +++ b/Management/appsettings.json @@ -8,10 +8,44 @@ "AllowedHosts": "*", "Auth": { "AllowDevBypass": false, - "EntraId": { - "Instance": "https://login.microsoftonline.com/", + + /* + * STAFF IDENTITY — Entra External ID (dev) / Entra org tenant (prod) + * + * PRODUCTION MIGRATION: update these three environment variables only. + * No code changes required. + * + * Auth__Staff__Instance → https://login.microsoftonline.com/ + * Auth__Staff__TenantId → new company org tenant ID + * Auth__Staff__ClientId → staff app registration in org tenant + * + * DEV: CIAM tenant used as placeholder (staff/client login looks identical). + * The API-level audience isolation is real regardless of tenant. + */ + "Staff": { + "Instance": "https://usimclients.ciamlogin.com/", "TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2", - "ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e" + "ClientId": "STAFF_APP_CLIENT_ID" } + }, + + /* + * GRAPH API — app-only credentials for reading Entra org tenant users. + * Used by AdminAccessController to list platform access users. + * + * TenantId and ClientId refer to the org tenant (thematrixpoint), + * NOT the CIAM tenant. ClientSecret must be injected via env var: + * Graph__ClientSecret = (Azure Container Apps env var) + * + * PREREQUISITES (one-time Entra portal steps): + * 1. App registration: AdPlatform Staff (b0f29246-...) + * 2. API permissions → Microsoft Graph → Application → User.Read.All + * 3. Grant admin consent + * 4. Create a client secret → copy value → set Graph__ClientSecret env var + */ + "Graph": { + "TenantId": "0be4c23a-6941-4bdb-b397-a4faf88de4b3", + "ClientId": "b0f29246-91e7-4615-96db-5de9b6f8da2e", + "ClientSecret": "" } } diff --git a/MetaApi/Configuration/MetaConfig.cs b/MetaApi/Configuration/MetaConfig.cs new file mode 100644 index 0000000..2a2a122 --- /dev/null +++ b/MetaApi/Configuration/MetaConfig.cs @@ -0,0 +1,90 @@ +namespace MetaApi.Configuration; + +/// +/// Root configuration for Meta Marketing API integration. +/// Bind to the "Meta" section in appsettings.json or environment variables. +/// +/// Meta uses System User tokens for server-to-server auth (no interactive OAuth flow). +/// These tokens don't expire if the System User remains active in Business Manager. +/// +public sealed class MetaConfig +{ + public const string SectionName = "Meta"; + + /// + /// Enable/disable real API calls. When false, the provider returns emulated responses. + /// Override via: Meta__EnableRealApi=true + /// + public bool EnableRealApi { get; set; } = false; + + /// + /// Graph API version (e.g. "v21.0"). + /// Meta requires explicit versioning in all API URLs. + /// + public string ApiVersion { get; set; } = "v21.0"; + + /// + /// Meta App ID from the Developer Portal. + /// + public string AppId { get; set; } = string.Empty; + + /// + /// Meta App Secret from the Developer Portal. + /// Store in Key Vault; inject via environment variable in prod. + /// + public string AppSecret { get; set; } = string.Empty; + + /// + /// System User Access Token for server-to-server API calls. + /// Generated in Business Manager → System Users → Generate Token. + /// Unlike OAuth tokens, these don't expire unless revoked. + /// Store in Key Vault; inject via environment variable in prod. + /// + public string SystemUserToken { get; set; } = string.Empty; + + /// + /// USIM's Business Manager ID. + /// All client ad accounts are created under this BM. + /// Format: numeric string (e.g. "123456789012345") + /// + public string BusinessManagerId { get; set; } = string.Empty; + + /// + /// Default ad account ID for testing/sandbox. + /// Format: act_XXXXXXXXXXXXXXX (with "act_" prefix) + /// + public string? DefaultAdAccountId { get; set; } + + /// + /// Request timeout in seconds for Graph API calls. + /// + public int TimeoutSeconds { get; set; } = 60; + + /// + /// Graph API base URL. Override for sandbox/testing if needed. + /// + public string GraphApiBaseUrl { get; set; } = "https://graph.facebook.com"; +} + +/// +/// Per-request Meta API context, populated from request and/or database. +/// +public sealed class MetaApiContext +{ + /// + /// Target Meta ad account ID for this request. + /// Format: act_XXXXXXXXXXXXXXX (with "act_" prefix) + /// + public string AdAccountId { get; set; } = string.Empty; + + /// + /// Business Manager ID that owns this ad account. + /// + public string? BusinessManagerId { get; set; } + + /// + /// Optional override access token for a specific account. + /// If null, the platform System User token from config is used. + /// + public string? AccessToken { get; set; } +} diff --git a/MetaApi/Controllers/InternalController.cs b/MetaApi/Controllers/InternalController.cs new file mode 100644 index 0000000..fc0bfc4 --- /dev/null +++ b/MetaApi/Controllers/InternalController.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using MetaApi.Models; +using MetaApi.Security; +using MetaApi.Services; + +namespace MetaApi.Controllers; + +/// +/// Internal API endpoint called by Gateway. +/// Protected by X-Internal-Key header validation. +/// +[ApiController] +[Route("internal")] +public sealed class InternalController : ControllerBase +{ + private readonly MetaMarketingService _metaAds; + private readonly ILogger _logger; + + public InternalController(MetaMarketingService metaAds, ILogger logger) + { + _metaAds = metaAds; + _logger = logger; + } + + /// + /// Health check - no auth required. + /// + [HttpGet("health")] + public IActionResult Health() + { + _logger.LogDebug("[InternalController] Health check"); + return Ok(new + { + ok = true, + service = "MetaApi", + timestamp = DateTimeOffset.UtcNow + }); + } + + /// + /// Main execution endpoint - Gateway calls this. + /// Protected by InternalAuthFilter. + /// + [ServiceFilter(typeof(InternalAuthFilter))] + [HttpPost("execute")] + public async Task Execute([FromBody] ProviderRequest request, CancellationToken ct) + { + _logger.LogInformation( + "[InternalController] Execute called | Operation={Operation} RequestId={RequestId}", + request?.Operation, request?.RequestId); + + if (request == null) + { + return BadRequest(ProviderResponse.Fail(null, "VALIDATION", "Request body is required")); + } + + if (string.IsNullOrWhiteSpace(request.Operation)) + { + return BadRequest(ProviderResponse.Fail(request.RequestId, "VALIDATION", "Operation is required")); + } + + var result = await _metaAds.ExecuteAsync(request, ct); + + if (result.Ok) + { + return Ok(result); + } + else + { + var statusCode = result.Error?.Code switch + { + "VALIDATION" => 400, + "NOT_FOUND" => 404, + "UNAUTHORIZED" => 401, + "FORBIDDEN" => 403, + "RATE_LIMITED" => 429, + _ => 400 + }; + + return StatusCode(statusCode, result); + } + } +} diff --git a/MetaApi/GATEWAY_INTEGRATION.md b/MetaApi/GATEWAY_INTEGRATION.md new file mode 100644 index 0000000..ddb997c --- /dev/null +++ b/MetaApi/GATEWAY_INTEGRATION.md @@ -0,0 +1,179 @@ +# Gateway Integration: Meta & TikTok Provider Routing + +## Overview + +The Gateway's `ExecutionService` already routes `provider="google"` to the GoogleApi container. +Adding Meta and TikTok follows the same pattern. + +--- + +## 1. Gateway ExecutionService Changes + +### GetProviderUrl() — add meta + tiktok routing + +```csharp +private string GetProviderUrl(string provider) +{ + return provider.ToLower() switch + { + "google" => Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL") + ?? _config["Providers:Google:Url"] + ?? "http://localhost:5200", + + "meta" => Environment.GetEnvironmentVariable("META_PROVIDER_URL") + ?? _config["Providers:Meta:Url"] + ?? "http://localhost:5300", + + "tiktok" => Environment.GetEnvironmentVariable("TIKTOK_PROVIDER_URL") + ?? _config["Providers:TikTok:Url"] + ?? "http://localhost:5400", + + _ => throw new ArgumentException($"Unknown provider: {provider}") + }; +} +``` + +### GetProviderKey() — add meta + tiktok internal keys + +```csharp +private string GetProviderKey(string provider) +{ + return provider.ToLower() switch + { + "google" => Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY") + ?? _config["Providers:Google:InternalKey"] ?? "", + + "meta" => Environment.GetEnvironmentVariable("META_INTERNAL_KEY") + ?? _config["Providers:Meta:InternalKey"] ?? "", + + "tiktok" => Environment.GetEnvironmentVariable("TIKTOK_INTERNAL_KEY") + ?? _config["Providers:TikTok:InternalKey"] ?? "", + + _ => "" + }; +} +``` + +--- + +## 2. Gateway Environment Variables (Azure Container Apps) + +Add to the Gateway container's env vars: + +```bash +# Meta +META_PROVIDER_URL=https://usim-adp-metaapi.internal..azurecontainerapps.io +META_INTERNAL_KEY= + +# TikTok +TIKTOK_PROVIDER_URL=https://usim-adp-tiktokapi.internal..azurecontainerapps.io +TIKTOK_INTERNAL_KEY= +``` + +--- + +## 3. Gateway appsettings.json — MultiChannel StatusMappings + +The Gateway already has a MultiChannel config section for status mapping. +Add/verify meta and tiktok entries: + +```json +{ + "MultiChannel": { + "google": { + "StatusMappings": { + "ENABLED": "active", + "PAUSED": "paused", + "REMOVED": "cancelled" + } + }, + "meta": { + "StatusMappings": { + "ACTIVE": "active", + "PAUSED": "paused", + "DELETED": "cancelled", + "ARCHIVED": "archived" + } + }, + "tiktok": { + "StatusMappings": { + "ENABLE": "active", + "DISABLE": "paused", + "DELETE": "cancelled" + } + } + } +} +``` + +--- + +## 4. Account Validation (Optional — implement when ready) + +Currently `ValidateGoogleAccountAsync` checks Google-specific account setup. +When ready, add equivalent methods: + +```csharp +// In ExecutionService or a dedicated AccountValidationService: +private async Task ValidateMetaAccountAsync(string adAccountId) { ... } +private async Task ValidateTikTokAccountAsync(string advertiserId) { ... } +``` + +These would verify the external account ID exists and is accessible +before forwarding operations to the provider containers. + +--- + +## 5. Database Views & Stored Procedures + +### Meta +```sql +-- vwMetaAccount: mirrors vwGoogleAccount for accNetwork='meta' +CREATE VIEW vwMetaAccount AS +SELECT accId, accClientId, accExternalAccountId, accLoginAccountId, ... +FROM tbAdAccount WHERE accNetwork = 'meta'; + +-- spMetaAccount: account linking/validation +-- spMetaCredential: token storage (System User token doesn't expire, but store for reference) +``` + +### TikTok +```sql +-- vwTikTokAccount: mirrors vwGoogleAccount for accNetwork='tiktok' +CREATE VIEW vwTikTokAccount AS +SELECT accId, accClientId, accExternalAccountId, accLoginAccountId, ... +FROM tbAdAccount WHERE accNetwork = 'tiktok'; + +-- spTikTokAccount: account linking/validation +-- spTikTokCredential: access token storage (doesn't expire unless revoked) +``` + +--- + +## 6. Port Map (All Services) + +| Service | Port | Container Name | +|---------|------|----------------| +| Gateway | 5000 | usim-adp-gateway | +| Creative | 5100 | usim-adp-creative | +| GoogleApi | 5200 | usim-adp-googleapi | +| MetaApi | 5300 | usim-adp-metaapi | +| TikTokApi | 5400 | usim-adp-tiktokapi | +| Management | 5500 | usim-adp-management | + +--- + +## 7. Testing Checklist + +For each new provider (meta, tiktok): + +- [ ] Container builds and starts locally +- [ ] `GET /` returns service info +- [ ] `GET /internal/health` returns ok +- [ ] `POST /internal/execute` with Ping operation works (no auth needed for Ping) +- [ ] `POST /internal/execute` rejects without X-Internal-Key +- [ ] `POST /internal/execute` with CreateCampaign returns emulated response +- [ ] Gateway routes `provider="meta"` / `provider="tiktok"` correctly +- [ ] Gateway passes X-Internal-Key header +- [ ] End-to-end: client app → Gateway → provider container → emulated response +- [ ] Swagger UI accessible at `/swagger` diff --git a/MetaApi/MetaApi.csproj b/MetaApi/MetaApi.csproj new file mode 100644 index 0000000..8807adf --- /dev/null +++ b/MetaApi/MetaApi.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + True + mcr.microsoft.com/dotnet/aspnet:8.0 + metaapi + + + + + + + + + + + + diff --git a/MetaApi/MetaApi.http b/MetaApi/MetaApi.http new file mode 100644 index 0000000..995347f --- /dev/null +++ b/MetaApi/MetaApi.http @@ -0,0 +1,6 @@ +@MetaApi_HostAddress = http://localhost:5064 + +GET {{MetaApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/MetaApi/Models/OperationPayloads.cs b/MetaApi/Models/OperationPayloads.cs new file mode 100644 index 0000000..8f73689 --- /dev/null +++ b/MetaApi/Models/OperationPayloads.cs @@ -0,0 +1,205 @@ +using System.Text.Json.Serialization; + +namespace MetaApi.Models; + +#region Campaign Payloads + +/// +/// Create a Meta campaign. +/// Meta hierarchy: Campaign → Ad Set → Ad +/// With Advantage+ (v25.0+), this uses the unified campaign API. +/// +public sealed class CreateCampaignPayload +{ + public string Name { get; set; } = string.Empty; + public MetaObjective Objective { get; set; } = MetaObjective.Conversions; + + /// + /// Campaign spending limit in account currency (in cents). + /// Null = no campaign spending limit. + /// + public long? SpendCapCents { get; set; } + + /// + /// Special ad categories required by Meta for housing, employment, credit, etc. + /// Empty = none (most campaigns). + /// + public List SpecialAdCategories { get; set; } = new(); + + public MetaCampaignStatus Status { get; set; } = MetaCampaignStatus.Paused; +} + +public sealed class GetCampaignPayload +{ + public string CampaignId { get; set; } = string.Empty; +} + +public sealed class UpdateCampaignPayload +{ + public string CampaignId { get; set; } = string.Empty; + public string? Name { get; set; } + public MetaCampaignStatus? Status { get; set; } + public long? SpendCapCents { get; set; } +} + +public sealed class ListCampaignsPayload +{ + public MetaCampaignStatus? StatusFilter { get; set; } + public int Limit { get; set; } = 50; + public string? After { get; set; } // Cursor-based pagination +} + +#endregion + +#region Ad Set Payloads + +/// +/// Create a Meta Ad Set (equivalent to Google Ad Group). +/// Ad Sets define targeting, budget, schedule, and bid strategy. +/// +public sealed class CreateAdSetPayload +{ + public string CampaignId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + + /// Daily budget in account currency cents. + public long DailyBudgetCents { get; set; } + + /// Lifetime budget in cents (alternative to daily). Null = use daily. + public long? LifetimeBudgetCents { get; set; } + + public string? StartTime { get; set; } // ISO 8601 + public string? EndTime { get; set; } // ISO 8601 + + public MetaBillingEvent BillingEvent { get; set; } = MetaBillingEvent.Impressions; + public MetaOptimizationGoal OptimizationGoal { get; set; } = MetaOptimizationGoal.LinkClicks; + + /// Target CPA bid amount in cents (for CPA bidding). + public long? BidAmountCents { get; set; } + + public MetaCampaignStatus Status { get; set; } = MetaCampaignStatus.Paused; +} + +#endregion + +#region Ad Account Payloads + +/// +/// Create a new ad account under USIM's Business Manager. +/// Requires Advanced Access to business_management permission. +/// +public sealed class CreateAdAccountPayload +{ + /// Display name for the new ad account. + public string Name { get; set; } = string.Empty; + + /// Currency code (e.g. "USD"). ISO 4217. + public string Currency { get; set; } = "USD"; + + /// Timezone (IANA format, e.g. "America/Los_Angeles"). Numeric TZ ID also accepted. + public int TimezoneId { get; set; } = 1; // Default: America/Los_Angeles + + /// End advertiser name/business for the account. + public string? EndAdvertiser { get; set; } + + /// Media agency managing the account. + public string? MediaAgency { get; set; } + + /// Partner (USIM). + public string? Partner { get; set; } +} + +#endregion + +#region Insights Payloads + +/// +/// Retrieve campaign performance metrics (Meta Insights API). +/// +public sealed class CampaignInsightsPayload +{ + public string CampaignId { get; set; } = string.Empty; + public string? DatePreset { get; set; } // e.g., "last_7d", "last_30d", "this_month" + public string? StartDate { get; set; } // YYYY-MM-DD (time_range) + public string? EndDate { get; set; } // YYYY-MM-DD (time_range) + public string Level { get; set; } = "campaign"; // campaign, adset, ad +} + +public sealed class AccountInsightsPayload +{ + public string? DatePreset { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } +} + +#endregion + +#region Enums + +/// +/// Meta campaign objectives. +/// As of API v25.0, Meta is consolidating to Outcome-Driven Ad Experiences (ODAX). +/// These map to the Advantage+ unified campaign objectives. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MetaObjective +{ + /// OUTCOME_AWARENESS - Brand awareness and reach + Awareness = 0, + + /// OUTCOME_TRAFFIC - Drive traffic to a destination + Traffic = 1, + + /// OUTCOME_ENGAGEMENT - Get more engagement (likes, comments, shares) + Engagement = 2, + + /// OUTCOME_LEADS - Generate leads + Leads = 3, + + /// OUTCOME_APP_PROMOTION - Drive app installs + AppPromotion = 4, + + /// OUTCOME_SALES - Drive conversions/sales + Conversions = 5 +} + +/// +/// Meta campaign/ad set status values. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MetaCampaignStatus +{ + Active = 0, + Paused = 1, + Deleted = 2, + Archived = 3 +} + +/// +/// Meta billing events for ad sets. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MetaBillingEvent +{ + Impressions = 0, + LinkClicks = 1, + ThruPlay = 2 +} + +/// +/// Meta optimization goals for ad sets. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MetaOptimizationGoal +{ + None = 0, + LinkClicks = 1, + Impressions = 2, + Reach = 3, + LandingPageViews = 4, + LeadGeneration = 5, + Conversions = 6, + Value = 7 +} + +#endregion diff --git a/MetaApi/Models/ProviderModels.cs b/MetaApi/Models/ProviderModels.cs new file mode 100644 index 0000000..6168b49 --- /dev/null +++ b/MetaApi/Models/ProviderModels.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MetaApi.Models; + +/// +/// Request from Gateway to MetaApi. +/// Identical contract to GoogleApi.Models.ProviderRequest for Gateway compatibility. +/// +public sealed class ProviderRequest +{ + /// + /// Operation to execute (e.g., "Ping", "CreateCampaign", "GetCampaignInsights") + /// + public string Operation { get; set; } = string.Empty; + + /// + /// Tenant/account ID - maps to Meta ad account ID (act_XXXXXXX). + /// Populated by Gateway from tbAdAccount.accExternalAccountId where accNetwork='meta'. + /// + public string? TenantId { get; set; } + + /// + /// Login customer ID - maps to Meta Business Manager ID. + /// In Meta's agency model, the BM owns and manages client ad accounts. + /// Populated by Gateway from tbAdAccount.accLoginAccountId. + /// + public string? LoginCustomerId { get; set; } + + /// + /// Correlation ID for request tracing + /// + public string? RequestId { get; set; } + + /// + /// Operation-specific payload + /// + public JsonElement? Payload { get; set; } + + /// + /// Deserialize payload to strongly-typed object + /// + public T GetPayload() where T : new() + { + if (Payload == null || Payload.Value.ValueKind == JsonValueKind.Null || Payload.Value.ValueKind == JsonValueKind.Undefined) + return new T(); + + try + { + return JsonSerializer.Deserialize(Payload.Value.GetRawText(), JsonOptions.Default) ?? new T(); + } + catch + { + return new T(); + } + } +} + +/// +/// Response from MetaApi to Gateway. +/// Identical contract to GoogleApi.Models.ProviderResponse. +/// +public sealed class ProviderResponse +{ + public bool Ok { get; set; } + public string? RequestId { get; set; } + public object? Data { get; set; } + public ProviderError? Error { get; set; } + + public static ProviderResponse Success(string? requestId, object? data = null) + => new() { Ok = true, RequestId = requestId, Data = data }; + + public static ProviderResponse Fail(string? requestId, string code, string message, object? detail = null) + => new() + { + Ok = false, + RequestId = requestId, + Error = new ProviderError { Code = code, Message = message, Detail = detail } + }; +} + +public sealed class ProviderError +{ + public string Code { get; set; } = "ERROR"; + public string Message { get; set; } = "Unknown error"; + public object? Detail { get; set; } +} + +internal static class JsonOptions +{ + public static readonly JsonSerializerOptions Default = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} diff --git a/MetaApi/Program.cs b/MetaApi/Program.cs new file mode 100644 index 0000000..a6a56e6 --- /dev/null +++ b/MetaApi/Program.cs @@ -0,0 +1,65 @@ +using MetaApi.Configuration; +using MetaApi.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Port binding (same pattern as GoogleApi) +var port = Environment.GetEnvironmentVariable("PORT") ?? "5300"; +builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); + +// Configuration +builder.Services.Configure(options => +{ + // Bind from appsettings Meta section + builder.Configuration.GetSection("Meta").Bind(options); + + // Environment variable overrides (Azure Container Apps pattern) + options.AppId = Environment.GetEnvironmentVariable("Meta__AppId") ?? options.AppId; + options.AppSecret = Environment.GetEnvironmentVariable("Meta__AppSecret") ?? options.AppSecret; + options.SystemUserToken = Environment.GetEnvironmentVariable("Meta__SystemUserToken") ?? options.SystemUserToken; + options.BusinessManagerId = Environment.GetEnvironmentVariable("Meta__BusinessManagerId") ?? options.BusinessManagerId; + options.ApiVersion = Environment.GetEnvironmentVariable("Meta__ApiVersion") ?? options.ApiVersion; + + var enableReal = Environment.GetEnvironmentVariable("Meta__EnableRealApi"); + if (bool.TryParse(enableReal, out var realApi)) + options.EnableRealApi = realApi; +}); + +// HTTP client for Graph API +builder.Services.AddHttpClient(client => +{ + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Controllers + Swagger +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new() { Title = "MetaApi", Version = "v1" }); +}); + +var app = builder.Build(); + +// Swagger (all environments - same as GoogleApi) +app.UseSwagger(); +app.UseSwaggerUI(); + +app.MapControllers(); + +app.MapGet("/", () => Results.Ok(new +{ + service = "MetaApi", + status = "running", + version = "1.0.0", + timestamp = DateTimeOffset.UtcNow +})); + +app.Logger.LogInformation("MetaApi starting on port {Port}", port); + +app.Run(); diff --git a/MetaApi/Properties/launchSettings.json b/MetaApi/Properties/launchSettings.json new file mode 100644 index 0000000..16fc6f1 --- /dev/null +++ b/MetaApi/Properties/launchSettings.json @@ -0,0 +1,52 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5064" + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7162;http://localhost:5064" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Container (.NET SDK)": { + "commandName": "SdkContainer", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:42518", + "sslPort": 44383 + } + } +} \ No newline at end of file diff --git a/MetaApi/README.md b/MetaApi/README.md new file mode 100644 index 0000000..6221a1b --- /dev/null +++ b/MetaApi/README.md @@ -0,0 +1,102 @@ +# MetaApi - Meta Marketing API Provider Service + +Standalone microservice for Meta (Facebook/Instagram) advertising integration. Mirrors the GoogleApi architecture pattern — the Gateway routes `provider="meta"` requests here via internal HTTP. + +## Architecture + +``` +Gateway ──(POST /internal/execute)──► MetaApi ──(Graph API)──► Meta Marketing API + X-Internal-Key auth │ + ├── Emulated mode (default) + └── Real API mode (Meta__EnableRealApi=true) +``` + +## Key Differences from GoogleApi + +| Aspect | GoogleApi | MetaApi | +|--------|-----------|---------| +| Auth | OAuth refresh tokens | System User token (no expiry) | +| SDK | Google.Ads.GoogleAds NuGet | HttpClient → graph.facebook.com | +| Account format | Numeric customer ID | `act_XXXXXXXXXXXXXXX` | +| Hierarchy | Campaign → Ad Group → Ad | Campaign → Ad Set → Ad | +| API versioning | SDK version | URL path (`/v21.0/`) | + +## Operations + +| Operation | Description | Real API | +|-----------|-------------|----------| +| Ping / TestPing | Health check | N/A | +| CreateCampaign | Create Meta campaign | ✅ | +| GetCampaign | Retrieve campaign details | ✅ | +| UpdateCampaign | Update name/status/budget | ✅ | +| ListCampaigns | List campaigns with filters | ✅ | +| GetCampaignInsights | Campaign performance metrics | Emulated only | +| GetAccountInsights | Account-level metrics | Emulated only | +| CreateAdAccount | Create ad account under BM | ✅ | +| ListAdAccounts | List BM-owned ad accounts | ✅ | + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `META_INTERNAL_KEY` | Yes | Gateway→MetaApi shared auth key | +| `Meta__EnableRealApi` | No | `false` (default) = emulated responses | +| `Meta__AppId` | For real API | Meta App ID from Developer Portal | +| `Meta__AppSecret` | For real API | Meta App Secret | +| `Meta__SystemUserToken` | For real API | System User token from Business Manager | +| `Meta__BusinessManagerId` | For real API | USIM's Business Manager numeric ID | +| `Meta__ApiVersion` | No | Graph API version (default: `v21.0`) | + +## Local Development + +```bash +dotnet run --project MetaApi +# → http://localhost:5300 +# → Swagger: http://localhost:5300/swagger +``` + +## Deploy to Azure Container Apps + +```bash +az containerapp create \ + --name usim-adp-metaapi \ + --resource-group USIM-AdPlatform \ + --environment usim-adp-env \ + --image /usim-adp-metaapi:latest \ + --target-port 5300 \ + --ingress internal \ + --env-vars \ + META_INTERNAL_KEY=secretref:meta-internal-key \ + Meta__EnableRealApi=false +``` + +## Gateway Integration + +Add to Gateway environment: +``` +META_PROVIDER_URL=https://usim-adp-metaapi.internal..azurecontainerapps.io +META_INTERNAL_KEY= +``` + +Gateway's `ExecutionService.GetProviderUrl()` already routes `provider="meta"` to `META_PROVIDER_URL`. + +## Project Structure + +``` +MetaApi/ +├── Configuration/ +│ └── MetaConfig.cs # Config + MetaApiContext +├── Controllers/ +│ └── InternalController.cs # /internal/execute + /internal/health +├── Models/ +│ ├── OperationPayloads.cs # Meta-specific request payloads + enums +│ └── ProviderModels.cs # ProviderRequest/Response (Gateway contract) +├── Security/ +│ └── InternalAuthFilter.cs # X-Internal-Key validation +├── Services/ +│ ├── MetaGraphClient.cs # HTTP wrapper for graph.facebook.com +│ └── MetaMarketingService.cs # Operation dispatcher (emulated/real) +├── Program.cs +├── appsettings.json +└── MetaApi.csproj +``` diff --git a/MetaApi/Security/InternalAuthFilter.cs b/MetaApi/Security/InternalAuthFilter.cs new file mode 100644 index 0000000..98c7d05 --- /dev/null +++ b/MetaApi/Security/InternalAuthFilter.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace MetaApi.Security; + +/// +/// Validates the X-Internal-Key header for internal service-to-service calls. +/// Gateway must provide the correct key to call MetaApi endpoints. +/// +public sealed class InternalAuthFilter : IAsyncActionFilter +{ + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public InternalAuthFilter(IConfiguration config, ILogger logger) + { + _config = config; + _logger = logger; + } + + public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var headerName = _config["InternalAuth:HeaderName"] ?? "X-Internal-Key"; + + // Try multiple sources for the key + var expectedKey = _config["InternalAuth:Key"] + ?? _config["META_INTERNAL_KEY"] + ?? Environment.GetEnvironmentVariable("META_INTERNAL_KEY"); + + if (string.IsNullOrWhiteSpace(expectedKey)) + { + _logger.LogError("[InternalAuth] No internal key configured - check META_INTERNAL_KEY env var"); + context.Result = new ObjectResult(new { error = "Internal auth key not configured" }) + { + StatusCode = 500 + }; + return Task.CompletedTask; + } + + if (!context.HttpContext.Request.Headers.TryGetValue(headerName, out var providedKey) || + string.IsNullOrWhiteSpace(providedKey)) + { + _logger.LogWarning("[InternalAuth] Missing {HeaderName} header", headerName); + context.Result = new UnauthorizedObjectResult(new { error = $"Missing {headerName} header" }); + return Task.CompletedTask; + } + + if (!string.Equals(providedKey.ToString(), expectedKey, StringComparison.Ordinal)) + { + _logger.LogWarning("[InternalAuth] Invalid key provided"); + context.Result = new UnauthorizedObjectResult(new { error = "Invalid internal auth key" }); + return Task.CompletedTask; + } + + _logger.LogDebug("[InternalAuth] Request authorized"); + return next(); + } +} diff --git a/MetaApi/Services/MetaGraphClient.cs b/MetaApi/Services/MetaGraphClient.cs new file mode 100644 index 0000000..a8b3499 --- /dev/null +++ b/MetaApi/Services/MetaGraphClient.cs @@ -0,0 +1,229 @@ +using System.Net.Http.Json; +using System.Text.Json; +using MetaApi.Configuration; +using Microsoft.Extensions.Options; + +namespace MetaApi.Services; + +/// +/// HTTP wrapper for Meta Graph API calls. +/// Handles authentication, API versioning, error parsing, and rate limiting. +/// +/// Meta Graph API pattern: +/// GET https://graph.facebook.com/{version}/{node-id}?access_token=...&fields=... +/// POST https://graph.facebook.com/{version}/{node-id}/{edge}?access_token=... +/// +public sealed class MetaGraphClient +{ + private readonly HttpClient _http; + private readonly MetaConfig _config; + private readonly ILogger _logger; + + public MetaGraphClient(HttpClient http, IOptions config, ILogger logger) + { + _http = http; + _config = config.Value; + _logger = logger; + + _http.BaseAddress = new Uri(_config.GraphApiBaseUrl.TrimEnd('/') + "/"); + _http.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds); + } + + /// + /// Whether real API calls are enabled. + /// + public bool IsRealApiEnabled => _config.EnableRealApi + && !string.IsNullOrWhiteSpace(_config.SystemUserToken); + + /// + /// GET a Graph API node with optional fields. + /// + public async Task GetAsync(string path, Dictionary? queryParams = null, CancellationToken ct = default) + { + var url = BuildUrl(path, queryParams); + _logger.LogDebug("[GraphClient] GET {Url}", SanitizeUrl(url)); + + try + { + var response = await _http.GetAsync(url, ct); + return await ParseResponseAsync(response, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + return GraphApiResponse.Error("TIMEOUT", "Meta API request timed out"); + } + catch (Exception ex) + { + _logger.LogError(ex, "[GraphClient] GET failed for {Path}", path); + return GraphApiResponse.Error("HTTP_ERROR", ex.Message); + } + } + + /// + /// POST to a Graph API edge with form-encoded or JSON body. + /// Meta's Marketing API generally uses form-encoded POST parameters. + /// + public async Task PostAsync(string path, Dictionary formData, CancellationToken ct = default) + { + var url = $"{_config.ApiVersion}/{path.TrimStart('/')}"; + + // Add access token to form data + var data = new Dictionary(formData) + { + ["access_token"] = GetAccessToken() + }; + + _logger.LogDebug("[GraphClient] POST {Url} (fields: {Fields})", url, string.Join(", ", formData.Keys)); + + try + { + var response = await _http.PostAsync(url, new FormUrlEncodedContent(data), ct); + return await ParseResponseAsync(response, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + return GraphApiResponse.Error("TIMEOUT", "Meta API request timed out"); + } + catch (Exception ex) + { + _logger.LogError(ex, "[GraphClient] POST failed for {Path}", path); + return GraphApiResponse.Error("HTTP_ERROR", ex.Message); + } + } + + /// + /// DELETE a Graph API node. + /// + public async Task DeleteAsync(string path, CancellationToken ct = default) + { + var url = BuildUrl(path); + _logger.LogDebug("[GraphClient] DELETE {Url}", SanitizeUrl(url)); + + try + { + var response = await _http.DeleteAsync(url, ct); + return await ParseResponseAsync(response, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "[GraphClient] DELETE failed for {Path}", path); + return GraphApiResponse.Error("HTTP_ERROR", ex.Message); + } + } + + // ================================================================ + // Internals + // ================================================================ + + private string GetAccessToken() + => _config.SystemUserToken; + + private string BuildUrl(string path, Dictionary? queryParams = null) + { + var qs = new Dictionary(queryParams ?? new()) + { + ["access_token"] = GetAccessToken() + }; + + var queryString = string.Join("&", qs.Select(kv => + $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); + + return $"{_config.ApiVersion}/{path.TrimStart('/')}?{queryString}"; + } + + /// Redact access token from URLs for logging. + private static string SanitizeUrl(string url) + => System.Text.RegularExpressions.Regex.Replace(url, @"access_token=[^&]+", "access_token=***"); + + private async Task ParseResponseAsync(HttpResponseMessage response, CancellationToken ct) + { + var body = await response.Content.ReadAsStringAsync(ct); + var statusCode = (int)response.StatusCode; + + if (string.IsNullOrWhiteSpace(body)) + { + return new GraphApiResponse + { + IsSuccess = response.IsSuccessStatusCode, + StatusCode = statusCode, + RawJson = body + }; + } + + try + { + var json = JsonDocument.Parse(body); + var root = json.RootElement; + + // Check for Meta error response format: + // { "error": { "message": "...", "type": "...", "code": 123, "error_subcode": 456 } } + if (root.TryGetProperty("error", out var errorProp) && errorProp.ValueKind == JsonValueKind.Object) + { + var errorMsg = errorProp.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown Meta API error"; + var errorType = errorProp.TryGetProperty("type", out var typ) ? typ.GetString() : "OAuthException"; + var errorCode = errorProp.TryGetProperty("code", out var cod) ? cod.GetInt32() : 0; + var errorSubcode = errorProp.TryGetProperty("error_subcode", out var sub) ? sub.GetInt32() : 0; + + _logger.LogWarning( + "[GraphClient] API error | Status={Status} Code={Code} Subcode={Subcode} Type={Type} Message={Message}", + statusCode, errorCode, errorSubcode, errorType, errorMsg); + + return new GraphApiResponse + { + IsSuccess = false, + StatusCode = statusCode, + ErrorMessage = errorMsg, + ErrorType = errorType, + ErrorCode = errorCode, + ErrorSubcode = errorSubcode, + RawJson = body + }; + } + + return new GraphApiResponse + { + IsSuccess = response.IsSuccessStatusCode, + StatusCode = statusCode, + Data = root, + RawJson = body + }; + } + catch (JsonException) + { + _logger.LogWarning("[GraphClient] Non-JSON response from Meta API: {Body}", body[..Math.Min(body.Length, 200)]); + return new GraphApiResponse + { + IsSuccess = false, + StatusCode = statusCode, + ErrorMessage = "Non-JSON response from Meta API", + RawJson = body + }; + } + } +} + +/// +/// Parsed response from Meta's Graph API. +/// +public sealed class GraphApiResponse +{ + public bool IsSuccess { get; set; } + public int StatusCode { get; set; } + public JsonElement? Data { get; set; } + public string? RawJson { get; set; } + + // Error fields (populated when Meta returns error envelope) + public string? ErrorMessage { get; set; } + public string? ErrorType { get; set; } + public int ErrorCode { get; set; } + public int ErrorSubcode { get; set; } + + public static GraphApiResponse Error(string code, string message) + => new() + { + IsSuccess = false, + StatusCode = 0, + ErrorMessage = message, + ErrorType = code + }; +} diff --git a/MetaApi/Services/MetaMarketingService.cs b/MetaApi/Services/MetaMarketingService.cs new file mode 100644 index 0000000..119a110 --- /dev/null +++ b/MetaApi/Services/MetaMarketingService.cs @@ -0,0 +1,528 @@ +using MetaApi.Configuration; +using MetaApi.Models; +using Microsoft.Extensions.Options; + +namespace MetaApi.Services; + +/// +/// Core service for Meta Marketing API operations. +/// Follows the same dual-mode pattern as GoogleAdsService: +/// - When EnableRealApi=false: returns emulated responses +/// - When EnableRealApi=true: makes real Graph API calls +/// +/// Meta campaign hierarchy: Campaign → Ad Set → Ad +/// Maps to platform model: Initiative → ChannelCampaign (meta) → provider entities +/// +public sealed class MetaMarketingService +{ + private readonly MetaConfig _config; + private readonly MetaGraphClient _graphClient; + private readonly ILogger _logger; + + public MetaMarketingService( + IOptions config, + MetaGraphClient graphClient, + ILogger logger) + { + _config = config.Value; + _graphClient = graphClient; + _logger = logger; + } + + public async Task ExecuteAsync(ProviderRequest request, CancellationToken ct) + { + var requestId = request.RequestId ?? Guid.NewGuid().ToString("N"); + var operation = (request.Operation ?? string.Empty).Trim(); + + _logger.LogInformation( + "[MetaAds] Executing {Operation} | RequestId={RequestId} TenantId={TenantId} RealApi={RealApi}", + operation, requestId, request.TenantId, _graphClient.IsRealApiEnabled); + + try + { + var context = new MetaApiContext + { + AdAccountId = NormalizeAdAccountId(request.TenantId ?? string.Empty), + BusinessManagerId = request.LoginCustomerId ?? _config.BusinessManagerId + }; + + var result = operation switch + { + "Ping" => Ping(requestId), + "TestPing" => Ping(requestId), + + // Campaign operations + "CreateCampaign" => await CreateCampaignAsync(request, context, requestId, ct), + "GetCampaign" => await GetCampaignAsync(request, context, requestId, ct), + "UpdateCampaign" => await UpdateCampaignAsync(request, context, requestId, ct), + "ListCampaigns" => await ListCampaignsAsync(request, context, requestId, ct), + + // Insights (reporting) + "GetCampaignInsights" => GetCampaignInsights(request, requestId), + "GetAccountInsights" => GetAccountInsights(request, requestId), + + // Account management + "CreateAdAccount" => await CreateAdAccountAsync(request, context, requestId, ct), + "ListAdAccounts" => await ListAdAccountsAsync(context, requestId, ct), + + "" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"), + _ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}") + }; + + _logger.LogInformation( + "[MetaAds] Completed {Operation} | RequestId={RequestId} Ok={Ok}", + operation, requestId, result.Ok); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "[MetaAds] Error in {Operation} | RequestId={RequestId}", operation, requestId); + return ProviderResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message); + } + } + + // ================================================================ + // Ping + // ================================================================ + + private ProviderResponse Ping(string requestId) + => ProviderResponse.Success(requestId, new + { + message = "MetaApi provider is healthy", + service = "MetaApi", + realApiEnabled = _graphClient.IsRealApiEnabled, + apiVersion = _config.ApiVersion, + businessManagerId = _config.BusinessManagerId, + timestamp = DateTimeOffset.UtcNow + }); + + // ================================================================ + // Campaign Operations + // ================================================================ + + private async Task CreateCampaignAsync( + ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.Name)) + return ProviderResponse.Fail(requestId, "VALIDATION", "Campaign name is required"); + + if (_graphClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdAccountId)) + return await CreateCampaignRealAsync(payload, context, requestId, ct); + + // Emulated response + var emulatedId = GenerateId().ToString(); + return ProviderResponse.Success(requestId, new + { + campaignId = emulatedId, + name = payload.Name, + objective = MapObjectiveToApi(payload.Objective), + status = MapStatusToApi(payload.Status), + adAccountId = context.AdAccountId, + effectiveStatus = "PAUSED", + createdTime = DateTimeOffset.UtcNow.ToString("o"), + emulated = true + }); + } + + private async Task CreateCampaignRealAsync( + CreateCampaignPayload payload, MetaApiContext context, string requestId, CancellationToken ct) + { + // POST /{ad-account-id}/campaigns + var formData = new Dictionary + { + ["name"] = payload.Name, + ["objective"] = MapObjectiveToApi(payload.Objective), + ["status"] = MapStatusToApi(payload.Status), + ["special_ad_categories"] = payload.SpecialAdCategories.Count > 0 + ? $"[{string.Join(",", payload.SpecialAdCategories.Select(c => $"\"{c}\""))}]" + : "[]" + }; + + if (payload.SpendCapCents.HasValue) + formData["spend_cap"] = payload.SpendCapCents.Value.ToString(); + + var result = await _graphClient.PostAsync($"{context.AdAccountId}/campaigns", formData, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "META_API_ERROR", + result.ErrorMessage ?? "Failed to create campaign", + new { metaErrorCode = result.ErrorCode, metaErrorSubcode = result.ErrorSubcode }); + + var campaignId = result.Data?.TryGetProperty("id", out var idProp) == true + ? idProp.GetString() : null; + + return ProviderResponse.Success(requestId, new + { + campaignId, + name = payload.Name, + objective = MapObjectiveToApi(payload.Objective), + status = MapStatusToApi(payload.Status), + adAccountId = context.AdAccountId, + emulated = false + }); + } + + private async Task GetCampaignAsync( + ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.CampaignId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"); + + if (_graphClient.IsRealApiEnabled) + return await GetCampaignRealAsync(payload.CampaignId, requestId, ct); + + // Emulated + return ProviderResponse.Success(requestId, new + { + campaignId = payload.CampaignId, + name = $"Emulated Campaign {payload.CampaignId}", + objective = "OUTCOME_TRAFFIC", + status = "PAUSED", + effectiveStatus = "PAUSED", + dailyBudget = "5000", + lifetimeBudget = "0", + createdTime = DateTimeOffset.UtcNow.AddDays(-7).ToString("o"), + updatedTime = DateTimeOffset.UtcNow.ToString("o"), + emulated = true + }); + } + + private async Task GetCampaignRealAsync( + string campaignId, string requestId, CancellationToken ct) + { + var fields = new Dictionary + { + ["fields"] = "id,name,objective,status,effective_status,daily_budget,lifetime_budget,created_time,updated_time,spend_cap,special_ad_categories" + }; + + var result = await _graphClient.GetAsync(campaignId, fields, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "META_API_ERROR", + result.ErrorMessage ?? "Failed to get campaign"); + + return ProviderResponse.Success(requestId, new + { + raw = result.Data, + emulated = false + }); + } + + private async Task UpdateCampaignAsync( + ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.CampaignId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"); + + if (_graphClient.IsRealApiEnabled) + return await UpdateCampaignRealAsync(payload, requestId, ct); + + // Emulated + return ProviderResponse.Success(requestId, new + { + campaignId = payload.CampaignId, + updated = true, + name = payload.Name, + status = payload.Status?.ToString()?.ToUpper() ?? "PAUSED", + emulated = true + }); + } + + private async Task UpdateCampaignRealAsync( + UpdateCampaignPayload payload, string requestId, CancellationToken ct) + { + var formData = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(payload.Name)) + formData["name"] = payload.Name; + + if (payload.Status.HasValue) + formData["status"] = MapStatusToApi(payload.Status.Value); + + if (payload.SpendCapCents.HasValue) + formData["spend_cap"] = payload.SpendCapCents.Value.ToString(); + + if (formData.Count == 0) + return ProviderResponse.Fail(requestId, "VALIDATION", "No fields to update"); + + var result = await _graphClient.PostAsync(payload.CampaignId, formData, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "META_API_ERROR", + result.ErrorMessage ?? "Failed to update campaign"); + + return ProviderResponse.Success(requestId, new + { + campaignId = payload.CampaignId, + updated = true, + emulated = false + }); + } + + private async Task ListCampaignsAsync( + ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (_graphClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdAccountId)) + return await ListCampaignsRealAsync(payload, context, requestId, ct); + + // Emulated + var campaigns = Enumerable.Range(1, 3).Select(i => new + { + id = GenerateId().ToString(), + name = $"Emulated Campaign {i}", + objective = "OUTCOME_TRAFFIC", + status = i == 1 ? "ACTIVE" : "PAUSED", + effectiveStatus = i == 1 ? "ACTIVE" : "PAUSED", + dailyBudget = (5000 * i).ToString(), + createdTime = DateTimeOffset.UtcNow.AddDays(-i * 7).ToString("o") + }); + + return ProviderResponse.Success(requestId, new + { + campaigns, + adAccountId = context.AdAccountId, + emulated = true + }); + } + + private async Task ListCampaignsRealAsync( + ListCampaignsPayload payload, MetaApiContext context, string requestId, CancellationToken ct) + { + var queryParams = new Dictionary + { + ["fields"] = "id,name,objective,status,effective_status,daily_budget,lifetime_budget,created_time,updated_time", + ["limit"] = payload.Limit.ToString() + }; + + if (payload.StatusFilter.HasValue) + { + var apiStatus = MapStatusToApi(payload.StatusFilter.Value); + queryParams["filtering"] = $"[{{\"field\":\"effective_status\",\"operator\":\"IN\",\"value\":[\"{apiStatus}\"]}}]"; + } + + if (!string.IsNullOrWhiteSpace(payload.After)) + queryParams["after"] = payload.After; + + var result = await _graphClient.GetAsync($"{context.AdAccountId}/campaigns", queryParams, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "META_API_ERROR", + result.ErrorMessage ?? "Failed to list campaigns"); + + return ProviderResponse.Success(requestId, new + { + raw = result.Data, + emulated = false + }); + } + + // ================================================================ + // Insights (Reporting) - emulated only for now + // ================================================================ + + private ProviderResponse GetCampaignInsights(ProviderRequest request, string requestId) + { + var payload = request.GetPayload(); + var rng = new Random(); + + var days = Enumerable.Range(0, 7).Select(i => + { + var date = DateTime.UtcNow.Date.AddDays(-i); + var impressions = rng.Next(1000, 50000); + var clicks = rng.Next(50, impressions / 10); + var spend = Math.Round(clicks * (rng.NextDouble() * 2 + 0.5), 2); + return new + { + dateStart = date.ToString("yyyy-MM-dd"), + dateStop = date.ToString("yyyy-MM-dd"), + impressions = impressions.ToString(), + clicks = clicks.ToString(), + spend = spend.ToString("F2"), + ctr = (clicks * 100.0 / impressions).ToString("F2"), + cpc = (spend / clicks).ToString("F2"), + cpm = (spend / impressions * 1000).ToString("F2") + }; + }).Reverse(); + + return ProviderResponse.Success(requestId, new + { + campaignId = payload.CampaignId, + insights = days, + emulated = true + }); + } + + private ProviderResponse GetAccountInsights(ProviderRequest request, string requestId) + { + var payload = request.GetPayload(); + return ProviderResponse.Success(requestId, new + { + totalSpend = "12450.00", + totalImpressions = "845230", + totalClicks = "23456", + totalConversions = "567", + ctr = "2.78", + cpc = "0.53", + dateRange = new { start = DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd"), end = DateTime.UtcNow.ToString("yyyy-MM-dd") }, + emulated = true + }); + } + + // ================================================================ + // Account Management + // ================================================================ + + private async Task CreateAdAccountAsync( + ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.Name)) + return ProviderResponse.Fail(requestId, "VALIDATION", "Account name is required"); + + if (string.IsNullOrWhiteSpace(context.BusinessManagerId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessManagerId is required"); + + if (_graphClient.IsRealApiEnabled) + return await CreateAdAccountRealAsync(payload, context, requestId, ct); + + // Emulated + return ProviderResponse.Success(requestId, new + { + adAccountId = $"act_{GenerateId()}", + name = payload.Name, + currency = payload.Currency, + businessManagerId = context.BusinessManagerId, + status = 1, // Meta: 1=Active, 2=Disabled, 3=Unsettled, 7=Pending Review, etc. + emulated = true + }); + } + + private async Task CreateAdAccountRealAsync( + CreateAdAccountPayload payload, MetaApiContext context, string requestId, CancellationToken ct) + { + // POST /{business-id}/adaccount + var formData = new Dictionary + { + ["name"] = payload.Name, + ["currency"] = payload.Currency, + ["timezone_id"] = payload.TimezoneId.ToString(), + ["end_advertiser"] = payload.EndAdvertiser ?? _config.BusinessManagerId, + ["media_agency"] = payload.MediaAgency ?? _config.BusinessManagerId, + ["partner"] = payload.Partner ?? _config.BusinessManagerId + }; + + var result = await _graphClient.PostAsync($"{context.BusinessManagerId}/adaccount", formData, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "META_API_ERROR", + result.ErrorMessage ?? "Failed to create ad account", + new { metaErrorCode = result.ErrorCode, metaErrorSubcode = result.ErrorSubcode }); + + var accountId = result.Data?.TryGetProperty("id", out var idProp) == true + ? idProp.GetString() : null; + + return ProviderResponse.Success(requestId, new + { + adAccountId = accountId, + name = payload.Name, + currency = payload.Currency, + businessManagerId = context.BusinessManagerId, + emulated = false + }); + } + + private async Task ListAdAccountsAsync( + MetaApiContext context, string requestId, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(context.BusinessManagerId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessManagerId is required"); + + if (_graphClient.IsRealApiEnabled) + { + var queryParams = new Dictionary + { + ["fields"] = "id,name,account_status,currency,timezone_name,amount_spent,balance", + ["limit"] = "100" + }; + + var result = await _graphClient.GetAsync($"{context.BusinessManagerId}/owned_ad_accounts", queryParams, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "META_API_ERROR", + result.ErrorMessage ?? "Failed to list ad accounts"); + + return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false }); + } + + // Emulated + var accounts = Enumerable.Range(1, 3).Select(i => new + { + id = $"act_{GenerateId()}", + name = $"Client Account {i}", + accountStatus = 1, + currency = "USD", + timezoneName = "America/Los_Angeles", + amountSpent = (i * 4500).ToString(), + balance = "0" + }); + + return ProviderResponse.Success(requestId, new + { + accounts, + businessManagerId = context.BusinessManagerId, + emulated = true + }); + } + + // ================================================================ + // Helpers + // ================================================================ + + /// + /// Ensure ad account ID has "act_" prefix (Meta requirement). + /// + private static string NormalizeAdAccountId(string id) + { + if (string.IsNullOrWhiteSpace(id)) return string.Empty; + return id.StartsWith("act_", StringComparison.OrdinalIgnoreCase) ? id : $"act_{id}"; + } + + /// + /// Map platform objective enum to Meta API string. + /// Uses ODAX (Outcome-Driven Ad Experiences) objective names as of v18.0+. + /// + private static string MapObjectiveToApi(MetaObjective objective) => objective switch + { + MetaObjective.Awareness => "OUTCOME_AWARENESS", + MetaObjective.Traffic => "OUTCOME_TRAFFIC", + MetaObjective.Engagement => "OUTCOME_ENGAGEMENT", + MetaObjective.Leads => "OUTCOME_LEADS", + MetaObjective.AppPromotion => "OUTCOME_APP_PROMOTION", + MetaObjective.Conversions => "OUTCOME_SALES", + _ => "OUTCOME_TRAFFIC" + }; + + /// + /// Map platform status enum to Meta API string. + /// + private static string MapStatusToApi(MetaCampaignStatus status) => status switch + { + MetaCampaignStatus.Active => "ACTIVE", + MetaCampaignStatus.Paused => "PAUSED", + MetaCampaignStatus.Deleted => "DELETED", + MetaCampaignStatus.Archived => "ARCHIVED", + _ => "PAUSED" + }; + + private static long GenerateId() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000 + Random.Shared.Next(999); +} diff --git a/MetaApi/appsettings.Development.json b/MetaApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/MetaApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/MetaApi/appsettings.json b/MetaApi/appsettings.json new file mode 100644 index 0000000..f426647 --- /dev/null +++ b/MetaApi/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Meta": { + "AppId": "", + "AppSecret": "", + "SystemUserToken": "", + "BusinessManagerId": "", + "ApiVersion": "v21.0", + "EnableRealApi": false, + "GraphApiBaseUrl": "https://graph.facebook.com", + "TimeoutSeconds": 30 + }, + "InternalKey": "dev-meta-internal-key-change-in-production" +} diff --git a/Registration/Data/IRegistrationDataService.cs b/Registration/Data/IRegistrationDataService.cs new file mode 100644 index 0000000..8a4c757 --- /dev/null +++ b/Registration/Data/IRegistrationDataService.cs @@ -0,0 +1,64 @@ +namespace Registration.Data; + +/// +/// Abstraction over registration data. +/// MockDataService for development, SqlDataService when DB is connected. +/// +public interface IRegistrationDataService +{ + Task GetPendingAsync(CancellationToken ct = default); + Task GetByIdAsync(string registrationId, CancellationToken ct = default); + Task RegisterAsync(RegisterRequest request, CancellationToken ct = default); + Task RejectAsync(string registrationId, string? reason, string? rejectedBy, CancellationToken ct = default); + Task CompleteAsync(string registrationId, string? platformClientId, CancellationToken ct = default); +} + +// ── Models ── + +public sealed class Applicant +{ + public string RegistrationId { get; set; } = ""; + public string BusinessName { get; set; } = ""; + public string? WebsiteUrl { get; set; } + public string? BusinessCategory { get; set; } + public string? BusinessDescription { get; set; } + public string? ContactName { get; set; } + public string? ContactEmail { get; set; } + public string? ContactPhone { get; set; } + public string? EntraSubjectId { get; set; } + public string? ClientCategory { get; set; } // General | Franchisee | Franchisor + public string Status { get; set; } = "Pending"; // Pending, Approved, Rejected + public bool PaymentVerified { get; set; } + public DateTime RegisteredUtc { get; set; } + public DateTime? ReviewedUtc { get; set; } + public string? ReviewedBy { get; set; } + public string? RejectionReason { get; set; } + public string? PlatformClientId { get; set; } // Set after approval +} + +public sealed class RegisterRequest +{ + public string? BusinessName { get; set; } + public string? WebsiteUrl { get; set; } + public string? BusinessCategory { get; set; } + public string? BusinessDescription { get; set; } + public string? ContactName { get; set; } + public string? ContactEmail { get; set; } + public string? ContactPhone { get; set; } + public string? EntraSubjectId { get; set; } + public string? ClientCategory { get; set; } // General | Franchisee | Franchisor +} + +public sealed class RegistrationListResult +{ + public bool Ok { get; set; } + public List Applicants { get; set; } = new(); + public int TotalCount { get; set; } +} + +public sealed class RegistrationResult +{ + public bool Ok { get; set; } + public string? Error { get; set; } + public string? RegistrationId { get; set; } +} diff --git a/Registration/Data/SqlDataService.cs b/Registration/Data/SqlDataService.cs new file mode 100644 index 0000000..6ce8426 --- /dev/null +++ b/Registration/Data/SqlDataService.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Registration.Data; + +/// +/// Real data service backed by dbRegistration. +/// Calls dbo.spRegistration with the standard @action/@rqst/@resp pattern. +/// +/// Activate by swapping DI registration in Program.cs: +/// services.AddSingleton<IRegistrationDataService, SqlDataService>(); +/// +public class SqlDataService : IRegistrationDataService +{ + private readonly SqlService _sql; + private readonly ILogger _log; + + private const string Proc = "dbo.spRegistration"; + + public SqlDataService(SqlService sql, ILogger log) + { + _sql = sql; + _log = log; + } + + public async Task GetPendingAsync(CancellationToken ct) + { + var resp = await _sql.ExecProcAsync(Proc, "pending", "{}", ct: ct); + return JsonSerializer.Deserialize(resp, JsonOpts) + ?? new() { Ok = false }; + } + + public async Task GetByIdAsync(string registrationId, CancellationToken ct) + { + var rqst = JsonSerializer.Serialize(new { registrationId }); + var resp = await _sql.ExecProcAsync(Proc, "get", rqst, ct: ct); + + using var doc = JsonDocument.Parse(resp); + if (doc.RootElement.TryGetProperty("applicant", out var app)) + return JsonSerializer.Deserialize(app.GetRawText(), JsonOpts); + + return null; + } + + public async Task RegisterAsync(RegisterRequest request, CancellationToken ct) + { + var rqst = JsonSerializer.Serialize(request, JsonOpts); + var resp = await _sql.ExecProcAsync(Proc, "register", rqst, ct: ct); + return JsonSerializer.Deserialize(resp, JsonOpts) + ?? new() { Ok = false, Error = "Deserialization failed" }; + } + + public async Task RejectAsync(string registrationId, string? reason, string? rejectedBy, CancellationToken ct) + { + var rqst = JsonSerializer.Serialize(new { registrationId, reason, rejectedBy }); + var resp = await _sql.ExecProcAsync(Proc, "reject", rqst, ct: ct); + return JsonSerializer.Deserialize(resp, JsonOpts) + ?? new() { Ok = false, Error = "Deserialization failed" }; + } + + public async Task CompleteAsync(string registrationId, string? platformClientId, CancellationToken ct) + { + var rqst = JsonSerializer.Serialize(new { registrationId, platformClientId }); + var resp = await _sql.ExecProcAsync(Proc, "complete", rqst, ct: ct); + return JsonSerializer.Deserialize(resp, JsonOpts) + ?? new() { Ok = false, Error = "Deserialization failed" }; + } + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; +} diff --git a/Registration/Data/SqlService.cs b/Registration/Data/SqlService.cs new file mode 100644 index 0000000..6ebf420 --- /dev/null +++ b/Registration/Data/SqlService.cs @@ -0,0 +1,82 @@ +using System.Data; +using System.Diagnostics; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Registration.Data; + +/// +/// Stored procedure executor — same @action/@rqst/@resp OUTPUT pattern +/// used by Gateway and Management. +/// +/// Uncomment registration in Program.cs when dbRegistration is ready. +/// Connection string: ConnectionStrings__Sql (env var) or ConnectionStrings:Sql (appsettings). +/// +public class SqlService +{ + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public SqlService(IConfiguration config, ILogger logger) + { + _config = config; + _logger = logger; + } + + private string GetConnectionString() + { + var cs = _config.GetConnectionString("Sql"); + if (string.IsNullOrWhiteSpace(cs)) + throw new InvalidOperationException("Missing ConnectionStrings:Sql"); + return cs; + } + + public async Task ExecProcAsync( + string procName, + string action, + string rqstJson, + int commandTimeoutSeconds = 30, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(procName)) + throw new ArgumentException("procName is required.", nameof(procName)); + if (string.IsNullOrWhiteSpace(rqstJson)) + rqstJson = "{}"; + + var sw = Stopwatch.StartNew(); + + try + { + await using var conn = new SqlConnection(GetConnectionString()); + await conn.OpenAsync(ct); + + await using var cmd = new SqlCommand(procName, conn) + { + CommandType = CommandType.StoredProcedure, + CommandTimeout = commandTimeoutSeconds + }; + + cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = action }); + cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqstJson }); + + var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1) { Direction = ParameterDirection.Output }; + cmd.Parameters.Add(pResp); + + await cmd.ExecuteNonQueryAsync(ct); + + var resp = pResp.Value as string ?? ""; + + sw.Stop(); + _logger.LogInformation("SQL ok: {Proc}.{Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds); + + return resp; + } + catch (Exception ex) + { + sw.Stop(); + _logger.LogError(ex, "SQL error: {Proc}.{Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds); + throw; + } + } +} diff --git a/Registration/Functions/RegistrationFunctions.cs b/Registration/Functions/RegistrationFunctions.cs new file mode 100644 index 0000000..8b1cbd4 --- /dev/null +++ b/Registration/Functions/RegistrationFunctions.cs @@ -0,0 +1,218 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using Registration.Data; +using System.Security.Claims; +using System.Text.Json; + +namespace Registration.Functions; + +/// +/// Registration HTTP endpoints. +/// +/// ENDPOINTS: +/// POST /api/registration/register — Public; requires Bearer token (CIAM JWT). +/// entraSubjectId is extracted from the validated +/// token — the client never supplies it. +/// +/// GET /api/registration/pending — Admin; requires Function key. +/// GET /api/registration/item/{id} — Admin; requires Function key. +/// POST /api/registration/action/{id}/reject — Admin; requires Function key. +/// POST /api/registration/action/{id}/complete — Admin; requires Function key. +/// GET /api/health — Anonymous. +/// +public class RegistrationFunctions +{ + private readonly IRegistrationDataService _data; + private readonly ILogger _log; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + public RegistrationFunctions(IRegistrationDataService data, ILogger log) + { + _data = data; + _log = log; + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /// + /// Extract the Entra Object ID from a validated CIAM JWT. + /// The OID claim is stable across sessions and providers (Google, Apple, Microsoft). + /// + private static string? GetEntraSubjectId(ClaimsPrincipal user) => + user.FindFirst("oid")?.Value + ?? user.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value + ?? user.FindFirst("sub")?.Value; + + // ── Public: Register ───────────────────────────────────────────────── + + /// + /// Register a new prospect. + /// + /// AuthorizationLevel.Anonymous at the trigger allows any caller with a valid + /// Bearer token — no Function key required from the browser. + /// The [Authorize] attribute ensures the JWT is present and valid before + /// the function body runs. entraSubjectId is extracted from token claims only. + /// + [Function("Register")] + [Authorize] + public async Task Register( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req, + CancellationToken ct) + { + // Identity comes from the validated JWT — never from the request body + var entraSubjectId = GetEntraSubjectId(req.HttpContext.User); + + if (string.IsNullOrEmpty(entraSubjectId)) + { + _log.LogWarning("[Registration] Register called without a valid token OID claim"); + return new UnauthorizedObjectResult(new { ok = false, error = "Valid authentication required" }); + } + + RegisterRequest? request; + try + { + request = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); + } + catch + { + return new BadRequestObjectResult(new { ok = false, error = "Invalid JSON body" }); + } + + if (request == null || string.IsNullOrWhiteSpace(request.BusinessName)) + return new BadRequestObjectResult(new { ok = false, error = "businessName is required" }); + + // Stamp the server-validated identity — client-supplied value is ignored + request.EntraSubjectId = entraSubjectId; + + _log.LogInformation("[Registration] POST register: {Name} by entra={EntraId}", + request.BusinessName, entraSubjectId); + + var result = await _data.RegisterAsync(request, ct); + + if (!result.Ok) + return new BadRequestObjectResult(result); + + return new OkObjectResult(result); + } + + // ── Admin: List pending ─────────────────────────────────────────────── + + /// List all pending registrations. Called by Management API with Function key. + [Function("GetPending")] + public async Task GetPending( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/pending")] HttpRequest req, + CancellationToken ct) + { + _log.LogInformation("[Registration] GET pending"); + var result = await _data.GetPendingAsync(ct); + return new OkObjectResult(result); + } + + // ── Admin: Get by ID ───────────────────────────────────────────────── + + /// Get a single applicant by registration ID. Called by Management API. + [Function("GetById")] + public async Task GetById( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/item/{registrationId}")] HttpRequest req, + string registrationId, + CancellationToken ct) + { + _log.LogInformation("[Registration] GET {Id}", registrationId); + + var applicant = await _data.GetByIdAsync(registrationId, ct); + if (applicant == null) + return new NotFoundObjectResult(new { ok = false, error = "Registration not found" }); + + return new OkObjectResult(new { ok = true, applicant }); + } + + // ── Admin: Reject ──────────────────────────────────────────────────── + + /// Reject a pending applicant. Called by Management after admin clicks Reject. + [Function("Reject")] + public async Task Reject( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/reject")] HttpRequest req, + string registrationId, + CancellationToken ct) + { + RejectBody? body = null; + try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); } + catch { /* optional body */ } + + _log.LogInformation("[Registration] POST reject: {Id} reason={Reason}", + registrationId, body?.Reason); + + var result = await _data.RejectAsync(registrationId, body?.Reason, body?.RejectedBy, ct); + + if (!result.Ok) + return new BadRequestObjectResult(result); + + return new OkObjectResult(result); + } + + // ── Admin: Complete (approved) ──────────────────────────────────────── + + /// + /// Mark a registration as approved/completed. + /// Called by Management after spClientManagement.create succeeds. + /// Receives the platformClientId to link the registration to the platform record. + /// + [Function("Complete")] + public async Task Complete( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/complete")] HttpRequest req, + string registrationId, + CancellationToken ct) + { + CompleteBody? body = null; + try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); } + catch { /* optional body */ } + + _log.LogInformation("[Registration] POST complete: {Id} → platform client {PlatformId}", + registrationId, body?.PlatformClientId); + + var result = await _data.CompleteAsync(registrationId, body?.PlatformClientId, ct); + + if (!result.Ok) + return new BadRequestObjectResult(result); + + return new OkObjectResult(result); + } + + // ── Health ─────────────────────────────────────────────────────────── + + /// Health check — anonymous, no auth required. + [Function("Health")] + public IActionResult Health( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequest req) + { + return new OkObjectResult(new + { + ok = true, + service = "registration", + mode = _data is Registration.Mock.MockDataService ? "mock" : "database", + timestamp = DateTime.UtcNow + }); + } +} + +// ── Request / Response Bodies ───────────────────────────────────────────── + +internal sealed class RejectBody +{ + public string? Reason { get; set; } + public string? RejectedBy { get; set; } +} + +internal sealed class CompleteBody +{ + public string? PlatformClientId { get; set; } +} diff --git a/Registration/Mock/MockDataService.cs b/Registration/Mock/MockDataService.cs new file mode 100644 index 0000000..a649bf2 --- /dev/null +++ b/Registration/Mock/MockDataService.cs @@ -0,0 +1,186 @@ +using Registration.Data; +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Registration.Mock; + +/// +/// In-memory mock registration data for development. +/// Persists across requests within a single Function host lifecycle. +/// State resets on cold start (by design — it's mock data). +/// +/// Swap to SqlDataService in Program.cs when dbRegistration is ready. +/// +public class MockDataService : IRegistrationDataService +{ + private readonly ConcurrentDictionary _store; + private readonly ILogger _log; + + public MockDataService(ILogger log) + { + _log = log; + _store = new ConcurrentDictionary(SeedData()); + _log.LogInformation("[Mock] Initialized with {Count} applicants", _store.Count); + } + + public Task GetPendingAsync(CancellationToken ct) + { + var pending = _store.Values + .Where(a => a.Status == "Pending") + .OrderBy(a => a.RegisteredUtc) + .ToList(); + + return Task.FromResult(new RegistrationListResult + { + Ok = true, + Applicants = pending, + TotalCount = pending.Count + }); + } + + public Task GetByIdAsync(string registrationId, CancellationToken ct) + { + _store.TryGetValue(registrationId, out var applicant); + return Task.FromResult(applicant); + } + + public Task RegisterAsync(RegisterRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.BusinessName)) + return Task.FromResult(new RegistrationResult { Ok = false, Error = "Business name is required" }); + + // Check for duplicate business name + if (_store.Values.Any(a => string.Equals(a.BusinessName, request.BusinessName, StringComparison.OrdinalIgnoreCase) + && a.Status != "Rejected")) + return Task.FromResult(new RegistrationResult { Ok = false, Error = "A registration with this name already exists" }); + + var id = Guid.NewGuid().ToString("D"); + var applicant = new Applicant + { + RegistrationId = id, + BusinessName = request.BusinessName!.Trim(), + WebsiteUrl = request.WebsiteUrl, + BusinessCategory = request.BusinessCategory, + BusinessDescription = request.BusinessDescription, + ContactName = request.ContactName, + ContactEmail = request.ContactEmail, + ContactPhone = request.ContactPhone, + EntraSubjectId = request.EntraSubjectId, + Status = "Pending", + PaymentVerified = false, + RegisteredUtc = DateTime.UtcNow + }; + + _store[id] = applicant; + _log.LogInformation("[Mock] New registration: {Name} ({Id})", applicant.BusinessName, id); + + return Task.FromResult(new RegistrationResult { Ok = true, RegistrationId = id }); + } + + public Task RejectAsync(string registrationId, string? reason, string? rejectedBy, CancellationToken ct) + { + if (!_store.TryGetValue(registrationId, out var applicant)) + return Task.FromResult(new RegistrationResult { Ok = false, Error = "Registration not found" }); + + if (applicant.Status != "Pending") + return Task.FromResult(new RegistrationResult { Ok = false, Error = $"Cannot reject — status is {applicant.Status}" }); + + applicant.Status = "Rejected"; + applicant.RejectionReason = reason; + applicant.ReviewedBy = rejectedBy; + applicant.ReviewedUtc = DateTime.UtcNow; + + _log.LogInformation("[Mock] Rejected: {Name} ({Id}) reason={Reason}", applicant.BusinessName, registrationId, reason); + + return Task.FromResult(new RegistrationResult { Ok = true, RegistrationId = registrationId }); + } + + public Task CompleteAsync(string registrationId, string? platformClientId, CancellationToken ct) + { + if (!_store.TryGetValue(registrationId, out var applicant)) + return Task.FromResult(new RegistrationResult { Ok = false, Error = "Registration not found" }); + + if (applicant.Status != "Pending") + return Task.FromResult(new RegistrationResult { Ok = false, Error = $"Cannot complete — status is {applicant.Status}" }); + + applicant.Status = "Approved"; + applicant.PlatformClientId = platformClientId; + applicant.ReviewedUtc = DateTime.UtcNow; + + _log.LogInformation("[Mock] Approved: {Name} ({Id}) → platform client {PlatformId}", + applicant.BusinessName, registrationId, platformClientId); + + return Task.FromResult(new RegistrationResult { Ok = true, RegistrationId = registrationId }); + } + + // ── Seed Data ─────────────────────────────────── + + private static IEnumerable> SeedData() + { + var applicants = new[] + { + new Applicant + { + RegistrationId = "reg-001", + BusinessName = "Bella's Boutique", + WebsiteUrl = "https://bellasboutique.com", + BusinessCategory = "retail", + BusinessDescription = "Women's fashion and accessories boutique in Orange County. Looking to expand online presence through targeted social and search ads.", + ContactName = "Bella Rodriguez", + ContactEmail = "bella@bellasboutique.com", + ContactPhone = "(714) 555-0142", + EntraSubjectId = "entra-mock-bella-001", + Status = "Pending", + PaymentVerified = true, + RegisteredUtc = DateTime.UtcNow.AddDays(-3) + }, + new Applicant + { + RegistrationId = "reg-002", + BusinessName = "Pacific Coast Plumbing", + WebsiteUrl = "https://pacificcoastplumbing.com", + BusinessCategory = "home_services", + BusinessDescription = "Full-service plumbing company serving LA and OC. Need help with Google Local Services ads and Maps visibility.", + ContactName = "Mike Chen", + ContactEmail = "mike@pcplumbing.com", + ContactPhone = "(562) 555-0198", + EntraSubjectId = "entra-mock-mike-002", + Status = "Pending", + PaymentVerified = true, + RegisteredUtc = DateTime.UtcNow.AddDays(-1) + }, + new Applicant + { + RegistrationId = "reg-003", + BusinessName = "Sunrise Dental Group", + WebsiteUrl = "https://sunrisedental.care", + BusinessCategory = "healthcare", + BusinessDescription = "Multi-location dental practice. Interested in running awareness campaigns for new patient acquisition across Google and Meta.", + ContactName = "Dr. Sarah Kim", + ContactEmail = "sarah@sunrisedental.care", + ContactPhone = "(949) 555-0267", + EntraSubjectId = "entra-mock-sarah-003", + Status = "Pending", + PaymentVerified = false, + RegisteredUtc = DateTime.UtcNow.AddHours(-6) + }, + new Applicant + { + RegistrationId = "reg-004", + BusinessName = "FreshBite Meal Prep", + WebsiteUrl = "https://freshbitemealprep.com", + BusinessCategory = "food_beverage", + BusinessDescription = "Healthy meal prep delivery service. $2k/month budget for Instagram and TikTok ads targeting health-conscious millennials.", + ContactName = "Jordan Williams", + ContactEmail = "jordan@freshbitemealprep.com", + ContactPhone = "(310) 555-0334", + EntraSubjectId = "entra-mock-jordan-004", + Status = "Pending", + PaymentVerified = true, + RegisteredUtc = DateTime.UtcNow.AddHours(-2) + }, + }; + + return applicants.Select(a => new KeyValuePair(a.RegistrationId, a)); + } +} diff --git a/Registration/Program.cs b/Registration/Program.cs new file mode 100644 index 0000000..ea40ddb --- /dev/null +++ b/Registration/Program.cs @@ -0,0 +1,36 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Identity.Web; +using Registration.Data; +using Registration.Mock; + +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices((context, services) => + { + services.AddApplicationInsightsTelemetryWorkerService(); + services.ConfigureFunctionsApplicationInsights(); + + // ============================================================= + // JWT Authentication — Entra External ID (CIAM) + // Validates Bearer tokens issued by usimclients.ciamlogin.com. + // AzureAd config is in local.settings.json (dev) or Function App + // Configuration (production) using AzureAd__ prefix. + // ============================================================= + services.AddAuthentication() + .AddMicrosoftIdentityWebApi(context.Configuration.GetSection("AzureAd")); + + services.AddAuthorization(); + + // ============================================================= + // Data layer — SqlDataService backed by dbRegistration. + // Connection string: ConnectionStrings:Sql in local.settings.json + // or the "Sql" connection string in Function App Configuration. + // ============================================================= + services.AddSingleton(); + services.AddSingleton(); + }) + .Build(); + +host.Run(); diff --git a/Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/appInsights1.arm.json b/Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/appInsights1.arm.json new file mode 100644 index 0000000..49901ad --- /dev/null +++ b/Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/appInsights1.arm.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "type": "string", + "defaultValue": "RG-GraeJones", + "metadata": { + "_parameterType": "resourceGroup", + "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking." + } + }, + "resourceGroupLocation": { + "type": "string", + "defaultValue": "westus", + "metadata": { + "_parameterType": "location", + "description": "Location of the resource group. Resource groups could have different location than resources." + } + }, + "resourceLocation": { + "type": "string", + "defaultValue": "[parameters('resourceGroupLocation')]", + "metadata": { + "_parameterType": "location", + "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there." + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "name": "[parameters('resourceGroupName')]", + "location": "[parameters('resourceGroupLocation')]", + "apiVersion": "2019-10-01" + }, + { + "type": "Microsoft.Resources/deployments", + "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('usim-adp-registration', subscription().subscriptionId)))]", + "resourceGroup": "[parameters('resourceGroupName')]", + "apiVersion": "2019-10-01", + "dependsOn": [ + "[parameters('resourceGroupName')]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "kind": "web", + "name": "usim-adp-registration", + "type": "microsoft.insights/components", + "location": "[parameters('resourceLocation')]", + "properties": {}, + "apiVersion": "2015-05-01" + } + ] + } + } + } + ], + "metadata": { + "_dependencyType": "appInsights.azure" + } +} \ No newline at end of file diff --git a/Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/profile.arm.json b/Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/profile.arm.json new file mode 100644 index 0000000..7a9cd08 --- /dev/null +++ b/Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/profile.arm.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_dependencyType": "compute.function.linux.appService" + }, + "parameters": { + "resourceGroupName": { + "type": "string", + "defaultValue": "RG-GraeJones", + "metadata": { + "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking." + } + }, + "resourceGroupLocation": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Location of the resource group. Resource groups could have different location than resources, however by default we use API versions from latest hybrid profile which support all locations for resource types we support." + } + }, + "resourceName": { + "type": "string", + "defaultValue": "usim-adp-registration", + "metadata": { + "description": "Name of the main resource to be created by this template." + } + }, + "resourceLocation": { + "type": "string", + "defaultValue": "[parameters('resourceGroupLocation')]", + "metadata": { + "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there." + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "name": "[parameters('resourceGroupName')]", + "location": "[parameters('resourceGroupLocation')]", + "apiVersion": "2019-10-01" + }, + { + "type": "Microsoft.Resources/deployments", + "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]", + "resourceGroup": "[parameters('resourceGroupName')]", + "apiVersion": "2019-10-01", + "dependsOn": [ + "[parameters('resourceGroupName')]" + ], + "properties": { + "mode": "Incremental", + "expressionEvaluationOptions": { + "scope": "inner" + }, + "parameters": { + "resourceGroupName": { + "value": "[parameters('resourceGroupName')]" + }, + "resourceGroupLocation": { + "value": "[parameters('resourceGroupLocation')]" + }, + "resourceName": { + "value": "[parameters('resourceName')]" + }, + "resourceLocation": { + "value": "[parameters('resourceLocation')]" + } + }, + "template": { + "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "type": "string" + }, + "resourceGroupLocation": { + "type": "string" + }, + "resourceName": { + "type": "string" + }, + "resourceLocation": { + "type": "string" + } + }, + "variables": { + "storage_name": "[toLower(concat('storage', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId))))]", + "appServicePlan_name": "[concat('Plan', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]", + "storage_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Storage/storageAccounts/', variables('storage_name'))]", + "appServicePlan_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/serverFarms/', variables('appServicePlan_name'))]", + "function_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/sites/', parameters('resourceName'))]" + }, + "resources": [ + { + "location": "[parameters('resourceLocation')]", + "name": "[parameters('resourceName')]", + "type": "Microsoft.Web/sites", + "apiVersion": "2015-08-01", + "tags": { + "[concat('hidden-related:', variables('appServicePlan_ResourceId'))]": "empty" + }, + "dependsOn": [ + "[variables('appServicePlan_ResourceId')]", + "[variables('storage_ResourceId')]" + ], + "kind": "functionapp", + "properties": { + "name": "[parameters('resourceName')]", + "kind": "functionapp", + "httpsOnly": true, + "reserved": false, + "serverFarmId": "[variables('appServicePlan_ResourceId')]", + "siteConfig": { + "alwaysOn": true, + "linuxFxVersion": "dotnet|3.1" + } + }, + "identity": { + "type": "SystemAssigned" + }, + "resources": [ + { + "name": "appsettings", + "type": "config", + "apiVersion": "2015-08-01", + "dependsOn": [ + "[variables('function_ResourceId')]" + ], + "properties": { + "AzureWebJobsStorage": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storage_name'), ';AccountKey=', listKeys(variables('storage_ResourceId'), '2017-10-01').keys[0].value, ';EndpointSuffix=', 'core.windows.net')]", + "FUNCTIONS_EXTENSION_VERSION": "~3", + "FUNCTIONS_WORKER_RUNTIME": "dotnet" + } + } + ] + }, + { + "location": "[parameters('resourceGroupLocation')]", + "name": "[variables('storage_name')]", + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2017-10-01", + "tags": { + "[concat('hidden-related:', concat('/providers/Microsoft.Web/sites/', parameters('resourceName')))]": "empty" + }, + "properties": { + "supportsHttpsTrafficOnly": true + }, + "sku": { + "name": "Standard_LRS" + }, + "kind": "Storage" + }, + { + "location": "[parameters('resourceGroupLocation')]", + "name": "[variables('appServicePlan_name')]", + "type": "Microsoft.Web/serverFarms", + "apiVersion": "2015-02-01", + "kind": "linux", + "properties": { + "name": "[variables('appServicePlan_name')]", + "sku": "Standard", + "workerSizeId": "0", + "reserved": true + } + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/storage1.arm.json b/Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/storage1.arm.json new file mode 100644 index 0000000..da98e41 --- /dev/null +++ b/Registration/Properties/ServiceDependencies/usim-adp-registration - Zip Deploy/storage1.arm.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "type": "string", + "defaultValue": "RG-GraeJones", + "metadata": { + "_parameterType": "resourceGroup", + "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking." + } + }, + "resourceGroupLocation": { + "type": "string", + "defaultValue": "westus", + "metadata": { + "_parameterType": "location", + "description": "Location of the resource group. Resource groups could have different location than resources." + } + }, + "resourceLocation": { + "type": "string", + "defaultValue": "[parameters('resourceGroupLocation')]", + "metadata": { + "_parameterType": "location", + "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there." + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "name": "[parameters('resourceGroupName')]", + "location": "[parameters('resourceGroupLocation')]", + "apiVersion": "2019-10-01" + }, + { + "type": "Microsoft.Resources/deployments", + "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('rggraejonesa164', subscription().subscriptionId)))]", + "resourceGroup": "[parameters('resourceGroupName')]", + "apiVersion": "2019-10-01", + "dependsOn": [ + "[parameters('resourceGroupName')]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "sku": { + "name": "Standard_LRS", + "tier": "Standard" + }, + "kind": "StorageV2", + "name": "rggraejonesa164", + "type": "Microsoft.Storage/storageAccounts", + "location": "[parameters('resourceLocation')]", + "apiVersion": "2017-10-01" + } + ] + } + } + } + ], + "metadata": { + "_dependencyType": "storage.azure" + } +} \ No newline at end of file diff --git a/Registration/Properties/launchSettings.json b/Registration/Properties/launchSettings.json new file mode 100644 index 0000000..cdfb88d --- /dev/null +++ b/Registration/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "Registration": { + "commandName": "Project", + "commandLineArgs": "--port 7270", + "launchBrowser": false + } + } +} \ No newline at end of file diff --git a/Registration/Properties/serviceDependencies.json b/Registration/Properties/serviceDependencies.json new file mode 100644 index 0000000..ce1d90c --- /dev/null +++ b/Registration/Properties/serviceDependencies.json @@ -0,0 +1,12 @@ +{ + "dependencies": { + "storage1": { + "type": "storage", + "connectionId": "AzureWebJobsStorage" + }, + "appInsights1": { + "type": "appInsights", + "connectionId": "APPLICATIONINSIGHTS_CONNECTION_STRING" + } + } +} \ No newline at end of file diff --git a/Registration/Properties/serviceDependencies.local.json b/Registration/Properties/serviceDependencies.local.json new file mode 100644 index 0000000..b804a28 --- /dev/null +++ b/Registration/Properties/serviceDependencies.local.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights.sdk" + }, + "storage1": { + "type": "storage.emulator", + "connectionId": "AzureWebJobsStorage" + } + } +} \ No newline at end of file diff --git a/Registration/Properties/serviceDependencies.usim-adp-registration - Zip Deploy.json b/Registration/Properties/serviceDependencies.usim-adp-registration - Zip Deploy.json new file mode 100644 index 0000000..95eeeb5 --- /dev/null +++ b/Registration/Properties/serviceDependencies.usim-adp-registration - Zip Deploy.json @@ -0,0 +1,14 @@ +{ + "dependencies": { + "storage1": { + "resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/Microsoft.Storage/storageAccounts/rggraejonesa164", + "type": "storage.azure", + "connectionId": "AzureWebJobsStorage" + }, + "appInsights1": { + "resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/microsoft.insights/components/usim-adp-registration", + "type": "appInsights.azure", + "connectionId": "APPLICATIONINSIGHTS_CONNECTION_STRING" + } + } +} \ No newline at end of file diff --git a/Registration/README.md b/Registration/README.md new file mode 100644 index 0000000..37701da --- /dev/null +++ b/Registration/README.md @@ -0,0 +1,91 @@ +# Registration Function + +Azure Function (isolated worker, .NET 8) for managing prospect registration in AdPlatform. + +## Architecture + +``` +Prospect → Registration Function → dbRegistration (future) +Admin Panel → Management API → Registration Function (proxy) + → spClientManagement (approve → dbAdPlatform) +``` + +Management validates admin sessions, then proxies registration calls to this Function. +The Function never touches `dbAdPlatform`. Management never touches `dbRegistration`. + +## Endpoints + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/registration/pending` | Function Key | List pending applicants | +| GET | `/api/registration/{id}` | Function Key | Get single applicant | +| POST | `/api/registration/register` | Function Key | New prospect signup | +| POST | `/api/registration/{id}/reject` | Function Key | Reject applicant | +| POST | `/api/registration/{id}/complete` | Function Key | Mark approved (called after platform client created) | +| GET | `/api/registration/health` | Anonymous | Health check | + +## Mock Mode (Current) + +Starts with 4 realistic test applicants in memory. State persists within a Function host +lifecycle and resets on cold start. No database required. + +To switch to mock mode, in `Program.cs`: +```csharp +services.AddSingleton(); +``` + +## Database Mode (Future) + +When `dbRegistration` is ready: + +1. Create the database and run the `spRegistration` stored proc migration +2. Set `ConnectionStrings:Sql` to the registration database connection string +3. In `Program.cs`, swap DI registration: +```csharp +services.AddSingleton(); +services.AddSingleton(); +``` + +The `SqlDataService` calls `dbo.spRegistration` with the standard `@action/@rqst/@resp OUTPUT` +pattern used across all AdPlatform services. + +## Local Development + +```bash +# Requires Azure Functions Core Tools +func start +``` + +Test with: +```bash +curl http://localhost:7071/api/registration/health +curl http://localhost:7071/api/registration/pending +``` + +## Deployment + +Deploy as an Azure Function App (Consumption or Flex Consumption plan). + +After deployment: +1. Copy the Function Key from Azure Portal → Function App → App Keys +2. Set in Management API config: + - `Registration:BaseUrl` = `https://your-function-app.azurewebsites.net/api` + - `Registration:FunctionKey` = `` + +These can be set as Azure Container App environment variables: +``` +Registration__BaseUrl=https://your-function-app.azurewebsites.net/api +Registration__FunctionKey= +``` + +## Mock Applicants + +The mock data includes 4 test applicants representing the target market +(small businesses with low ad spend thresholds): + +| Business | Category | Payment Verified | Days Waiting | +|----------|----------|-----------------|-------------| +| Bella's Boutique | Retail | Yes | 3 | +| Pacific Coast Plumbing | Home Services | Yes | 1 | +| Sunrise Dental Group | Healthcare | No | ~0.25 | +| FreshBite Meal Prep | Food & Beverage | Yes | ~0.08 | diff --git a/Registration/Registration.csproj b/Registration/Registration.csproj new file mode 100644 index 0000000..6d673e3 --- /dev/null +++ b/Registration/Registration.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + v4 + Exe + enable + enable + Registration + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + diff --git a/Registration/host.json b/Registration/host.json new file mode 100644 index 0000000..85e95ab --- /dev/null +++ b/Registration/host.json @@ -0,0 +1,29 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + }, + "logLevel": { + "default": "Information", + "Registration": "Information" + } + }, + "extensions": { + "http": { + "routePrefix": "api", + "cors": { + "allowedOrigins": [ + "https://adpregist.usimdev.com", + "http://localhost:3001" + ], + "allowedHeaders": [ "*" ], + "allowedMethods": [ "GET", "POST", "OPTIONS" ] + } + } + } +} diff --git a/Registration/local.settings.json b/Registration/local.settings.json new file mode 100644 index 0000000..c35489f --- /dev/null +++ b/Registration/local.settings.json @@ -0,0 +1,16 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" + }, + "ConnectionStrings": { + "Sql": "Server=usimdev.database.windows.net;Database=dbRegistration;User Id=appAdPlatformReg;Password=YOUR_PASSWORD_HERE;TrustServerCertificate=True;" + }, + "AzureAd": { + "Instance": "https://usimclients.ciamlogin.com/", + "TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2", + "ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e", + "Audience": "154c9111-14a0-4c0f-8132-7bc68254a74e" + } +} diff --git a/TikTokApi/Configuration/TikTokConfig.cs b/TikTokApi/Configuration/TikTokConfig.cs new file mode 100644 index 0000000..cc3a0d2 --- /dev/null +++ b/TikTokApi/Configuration/TikTokConfig.cs @@ -0,0 +1,95 @@ +namespace TikTokApi.Configuration; + +/// +/// Root configuration for TikTok Marketing API integration. +/// Bind to the "TikTok" section in appsettings.json or environment variables. +/// +/// TikTok auth model: +/// - Register as developer → create app → OAuth authorize → get access_token +/// - Access tokens do NOT expire unless the advertiser revokes authorization +/// - Token is passed via "Access-Token" HTTP header (not query param like Meta) +/// +/// Agency model: +/// - Business Center (BC) is the parent entity (equivalent to Google MCC / Meta BM) +/// - BC owns advertiser accounts (ad accounts) created for clients +/// - BC API: /bc/advertiser/create, /bc/asset/get, /bc/transfer (fund management) +/// +public sealed class TikTokConfig +{ + public const string SectionName = "TikTok"; + + /// + /// Enable/disable real API calls. When false, the provider returns emulated responses. + /// Override via: TikTok__EnableRealApi=true + /// + public bool EnableRealApi { get; set; } = false; + + /// + /// TikTok Marketing API version (e.g. "v1.3"). + /// Used in URL path: /open_api/v1.3/... + /// + public string ApiVersion { get; set; } = "v1.3"; + + /// + /// TikTok App ID from the Developer Portal (My Apps). + /// + public string AppId { get; set; } = string.Empty; + + /// + /// TikTok App Secret from the Developer Portal. + /// Store in Key Vault; inject via environment variable in prod. + /// + public string AppSecret { get; set; } = string.Empty; + + /// + /// Long-lived access token obtained via OAuth authorization flow. + /// Does NOT expire unless the advertiser revokes authorization. + /// Passed in "Access-Token" header on all API calls. + /// Store in Key Vault; inject via environment variable in prod. + /// + public string AccessToken { get; set; } = string.Empty; + + /// + /// USIM's Business Center ID. + /// All client advertiser accounts are created under this BC. + /// Format: numeric string (e.g. "7123456789012345678") + /// + public string BusinessCenterId { get; set; } = string.Empty; + + /// + /// Default advertiser ID for testing/sandbox. + /// Format: numeric string (e.g. "7123456789012345678") + /// + public string? DefaultAdvertiserId { get; set; } + + /// + /// Request timeout in seconds for API calls. + /// + public int TimeoutSeconds { get; set; } = 60; + + /// + /// TikTok Marketing API base URL. + /// Production: https://business-api.tiktok.com + /// Sandbox: https://sandbox-ads.tiktok.com + /// + public string ApiBaseUrl { get; set; } = "https://business-api.tiktok.com"; +} + +/// +/// Per-request TikTok API context extracted from ProviderRequest. +/// +public sealed class TikTokApiContext +{ + /// + /// TikTok advertiser (ad account) ID. + /// Derived from ProviderRequest.TenantId (accExternalAccountId). + /// Format: numeric string. + /// + public string AdvertiserId { get; set; } = string.Empty; + + /// + /// Business Center ID that owns/manages the advertiser account. + /// Falls back to TikTokConfig.BusinessCenterId if not in request. + /// + public string? BusinessCenterId { get; set; } +} diff --git a/TikTokApi/Controllers/InternalController.cs b/TikTokApi/Controllers/InternalController.cs new file mode 100644 index 0000000..1edb05a --- /dev/null +++ b/TikTokApi/Controllers/InternalController.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using TikTokApi.Models; +using TikTokApi.Security; +using TikTokApi.Services; + +namespace TikTokApi.Controllers; + +/// +/// Internal API endpoint called by Gateway. +/// Protected by X-Internal-Key header validation. +/// +[ApiController] +[Route("internal")] +public sealed class InternalController : ControllerBase +{ + private readonly TikTokMarketingService _tikTokAds; + private readonly ILogger _logger; + + public InternalController(TikTokMarketingService tikTokAds, ILogger logger) + { + _tikTokAds = tikTokAds; + _logger = logger; + } + + /// + /// Health check - no auth required. + /// + [HttpGet("health")] + public IActionResult Health() + { + _logger.LogDebug("[InternalController] Health check"); + return Ok(new + { + ok = true, + service = "TikTokApi", + timestamp = DateTimeOffset.UtcNow + }); + } + + /// + /// Main execution endpoint - Gateway calls this. + /// Protected by InternalAuthFilter. + /// + [ServiceFilter(typeof(InternalAuthFilter))] + [HttpPost("execute")] + public async Task Execute([FromBody] ProviderRequest request, CancellationToken ct) + { + _logger.LogInformation( + "[InternalController] Execute called | Operation={Operation} RequestId={RequestId}", + request?.Operation, request?.RequestId); + + if (request == null) + { + return BadRequest(ProviderResponse.Fail(null, "VALIDATION", "Request body is required")); + } + + if (string.IsNullOrWhiteSpace(request.Operation)) + { + return BadRequest(ProviderResponse.Fail(request.RequestId, "VALIDATION", "Operation is required")); + } + + var result = await _tikTokAds.ExecuteAsync(request, ct); + + if (result.Ok) + { + return Ok(result); + } + else + { + var statusCode = result.Error?.Code switch + { + "VALIDATION" => 400, + "NOT_FOUND" => 404, + "UNAUTHORIZED" => 401, + "FORBIDDEN" => 403, + "RATE_LIMITED" => 429, + _ => 400 + }; + + return StatusCode(statusCode, result); + } + } +} diff --git a/TikTokApi/GATEWAY_INTEGRATION.md b/TikTokApi/GATEWAY_INTEGRATION.md new file mode 100644 index 0000000..ddb997c --- /dev/null +++ b/TikTokApi/GATEWAY_INTEGRATION.md @@ -0,0 +1,179 @@ +# Gateway Integration: Meta & TikTok Provider Routing + +## Overview + +The Gateway's `ExecutionService` already routes `provider="google"` to the GoogleApi container. +Adding Meta and TikTok follows the same pattern. + +--- + +## 1. Gateway ExecutionService Changes + +### GetProviderUrl() — add meta + tiktok routing + +```csharp +private string GetProviderUrl(string provider) +{ + return provider.ToLower() switch + { + "google" => Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL") + ?? _config["Providers:Google:Url"] + ?? "http://localhost:5200", + + "meta" => Environment.GetEnvironmentVariable("META_PROVIDER_URL") + ?? _config["Providers:Meta:Url"] + ?? "http://localhost:5300", + + "tiktok" => Environment.GetEnvironmentVariable("TIKTOK_PROVIDER_URL") + ?? _config["Providers:TikTok:Url"] + ?? "http://localhost:5400", + + _ => throw new ArgumentException($"Unknown provider: {provider}") + }; +} +``` + +### GetProviderKey() — add meta + tiktok internal keys + +```csharp +private string GetProviderKey(string provider) +{ + return provider.ToLower() switch + { + "google" => Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY") + ?? _config["Providers:Google:InternalKey"] ?? "", + + "meta" => Environment.GetEnvironmentVariable("META_INTERNAL_KEY") + ?? _config["Providers:Meta:InternalKey"] ?? "", + + "tiktok" => Environment.GetEnvironmentVariable("TIKTOK_INTERNAL_KEY") + ?? _config["Providers:TikTok:InternalKey"] ?? "", + + _ => "" + }; +} +``` + +--- + +## 2. Gateway Environment Variables (Azure Container Apps) + +Add to the Gateway container's env vars: + +```bash +# Meta +META_PROVIDER_URL=https://usim-adp-metaapi.internal..azurecontainerapps.io +META_INTERNAL_KEY= + +# TikTok +TIKTOK_PROVIDER_URL=https://usim-adp-tiktokapi.internal..azurecontainerapps.io +TIKTOK_INTERNAL_KEY= +``` + +--- + +## 3. Gateway appsettings.json — MultiChannel StatusMappings + +The Gateway already has a MultiChannel config section for status mapping. +Add/verify meta and tiktok entries: + +```json +{ + "MultiChannel": { + "google": { + "StatusMappings": { + "ENABLED": "active", + "PAUSED": "paused", + "REMOVED": "cancelled" + } + }, + "meta": { + "StatusMappings": { + "ACTIVE": "active", + "PAUSED": "paused", + "DELETED": "cancelled", + "ARCHIVED": "archived" + } + }, + "tiktok": { + "StatusMappings": { + "ENABLE": "active", + "DISABLE": "paused", + "DELETE": "cancelled" + } + } + } +} +``` + +--- + +## 4. Account Validation (Optional — implement when ready) + +Currently `ValidateGoogleAccountAsync` checks Google-specific account setup. +When ready, add equivalent methods: + +```csharp +// In ExecutionService or a dedicated AccountValidationService: +private async Task ValidateMetaAccountAsync(string adAccountId) { ... } +private async Task ValidateTikTokAccountAsync(string advertiserId) { ... } +``` + +These would verify the external account ID exists and is accessible +before forwarding operations to the provider containers. + +--- + +## 5. Database Views & Stored Procedures + +### Meta +```sql +-- vwMetaAccount: mirrors vwGoogleAccount for accNetwork='meta' +CREATE VIEW vwMetaAccount AS +SELECT accId, accClientId, accExternalAccountId, accLoginAccountId, ... +FROM tbAdAccount WHERE accNetwork = 'meta'; + +-- spMetaAccount: account linking/validation +-- spMetaCredential: token storage (System User token doesn't expire, but store for reference) +``` + +### TikTok +```sql +-- vwTikTokAccount: mirrors vwGoogleAccount for accNetwork='tiktok' +CREATE VIEW vwTikTokAccount AS +SELECT accId, accClientId, accExternalAccountId, accLoginAccountId, ... +FROM tbAdAccount WHERE accNetwork = 'tiktok'; + +-- spTikTokAccount: account linking/validation +-- spTikTokCredential: access token storage (doesn't expire unless revoked) +``` + +--- + +## 6. Port Map (All Services) + +| Service | Port | Container Name | +|---------|------|----------------| +| Gateway | 5000 | usim-adp-gateway | +| Creative | 5100 | usim-adp-creative | +| GoogleApi | 5200 | usim-adp-googleapi | +| MetaApi | 5300 | usim-adp-metaapi | +| TikTokApi | 5400 | usim-adp-tiktokapi | +| Management | 5500 | usim-adp-management | + +--- + +## 7. Testing Checklist + +For each new provider (meta, tiktok): + +- [ ] Container builds and starts locally +- [ ] `GET /` returns service info +- [ ] `GET /internal/health` returns ok +- [ ] `POST /internal/execute` with Ping operation works (no auth needed for Ping) +- [ ] `POST /internal/execute` rejects without X-Internal-Key +- [ ] `POST /internal/execute` with CreateCampaign returns emulated response +- [ ] Gateway routes `provider="meta"` / `provider="tiktok"` correctly +- [ ] Gateway passes X-Internal-Key header +- [ ] End-to-end: client app → Gateway → provider container → emulated response +- [ ] Swagger UI accessible at `/swagger` diff --git a/TikTokApi/Models/OperationPayloads.cs b/TikTokApi/Models/OperationPayloads.cs new file mode 100644 index 0000000..31a142b --- /dev/null +++ b/TikTokApi/Models/OperationPayloads.cs @@ -0,0 +1,326 @@ +using System.Text.Json.Serialization; + +namespace TikTokApi.Models; + +#region Campaign Payloads + +/// +/// Create a TikTok campaign. +/// TikTok hierarchy: Campaign → Ad Group → Ad (same as Google, unlike Meta's Ad Set). +/// Campaign sets the objective and budget type. +/// +public sealed class CreateCampaignPayload +{ + public string Name { get; set; } = string.Empty; + public TikTokObjective Objective { get; set; } = TikTokObjective.Traffic; + + /// + /// Budget mode. TikTok supports BUDGET_MODE_DAY and BUDGET_MODE_TOTAL at campaign level. + /// + public TikTokBudgetMode BudgetMode { get; set; } = TikTokBudgetMode.Day; + + /// + /// Budget amount in account currency (float, e.g., 50.00). + /// Minimum varies by country (typically $50/day for campaign-level budget). + /// Null = no campaign budget (budget set at ad group level). + /// + public decimal? Budget { get; set; } + + /// + /// Special industry categories: HOUSING, CREDIT, EMPLOYMENT. + /// Empty = none (most campaigns). + /// + public List SpecialIndustries { get; set; } = new(); + + public TikTokCampaignStatus Status { get; set; } = TikTokCampaignStatus.Disable; +} + +public sealed class GetCampaignPayload +{ + public string CampaignId { get; set; } = string.Empty; +} + +public sealed class UpdateCampaignPayload +{ + public string CampaignId { get; set; } = string.Empty; + public string? Name { get; set; } + public TikTokCampaignStatus? Status { get; set; } + public decimal? Budget { get; set; } +} + +public sealed class ListCampaignsPayload +{ + /// + /// Filter by status. Null = return all. + /// TikTok supports filtering by multiple statuses. + /// + public TikTokCampaignStatus? StatusFilter { get; set; } + + public int PageSize { get; set; } = 50; + public int Page { get; set; } = 1; +} + +#endregion + +#region Ad Group Payloads + +/// +/// Create a TikTok Ad Group (equivalent to Google Ad Group / Meta Ad Set). +/// Ad Groups define targeting, budget, schedule, placement, and bid strategy. +/// +public sealed class CreateAdGroupPayload +{ + public string CampaignId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + + /// Budget amount in account currency (float). + public decimal Budget { get; set; } + + public TikTokBudgetMode BudgetMode { get; set; } = TikTokBudgetMode.Day; + + /// Schedule start time in UTC (ISO 8601 / "yyyy-MM-dd HH:mm:ss"). + public string? ScheduleStartTime { get; set; } + + /// Schedule end time in UTC. Null = no end date. + public string? ScheduleEndTime { get; set; } + + public TikTokOptimizationGoal OptimizationGoal { get; set; } = TikTokOptimizationGoal.Click; + + public TikTokBillingEvent BillingEvent { get; set; } = TikTokBillingEvent.CPC; + + /// Bid amount in account currency. Required for non-auto-bid strategies. + public decimal? BidPrice { get; set; } + + /// + /// Placement: list of placements. Default = automatic (PLACEMENT_TYPE_AUTOMATIC). + /// Options: PLACEMENT_TIKTOK, PLACEMENT_PANGLE, PLACEMENT_GLOBALAPP_BUNDLE + /// + public List Placements { get; set; } = new(); + + /// Target location codes (country/region codes). + public List LocationIds { get; set; } = new(); + + public TikTokCampaignStatus Status { get; set; } = TikTokCampaignStatus.Disable; +} + +#endregion + +#region Ad Account (Advertiser) Payloads + +/// +/// Create a new advertiser account under USIM's Business Center. +/// Endpoint: POST /bc/advertiser/create +/// Requires BC admin access. +/// +public sealed class CreateAdvertiserPayload +{ + /// Display name for the new advertiser account. + public string Name { get; set; } = string.Empty; + + /// Currency code (e.g. "USD"). ISO 4217. + public string Currency { get; set; } = "USD"; + + /// + /// Timezone string. TikTok uses Olson/IANA format. + /// e.g., "America/Los_Angeles", "UTC", "Europe/London" + /// + public string Timezone { get; set; } = "America/Los_Angeles"; + + /// Advertiser's company name. + public string Company { get; set; } = string.Empty; + + /// Industry ID. Use /tool/industry/ endpoint to get valid IDs. + public string? IndustryId { get; set; } + + /// Advertiser's contact email. + public string? ContactEmail { get; set; } + + /// Advertiser's contact phone number. + public string? ContactPhone { get; set; } +} + +/// +/// List advertiser accounts under the Business Center. +/// Endpoint: GET /bc/advertiser/get +/// +public sealed class ListAdvertisersPayload +{ + public int PageSize { get; set; } = 50; + public int Page { get; set; } = 1; +} + +#endregion + +#region Insights / Reporting Payloads + +/// +/// Retrieve campaign/ad group/ad performance metrics. +/// TikTok Reporting API: POST /report/integrated/get/ +/// +public sealed class ReportPayload +{ + /// + /// Report type: BASIC, AUDIENCE, PLAYABLE_MATERIAL, CATALOG + /// + public string ReportType { get; set; } = "BASIC"; + + /// + /// Data level: AUCTION_CAMPAIGN, AUCTION_ADGROUP, AUCTION_AD + /// + public string DataLevel { get; set; } = "AUCTION_CAMPAIGN"; + + /// + /// Dimensions to group by (e.g., ["campaign_id", "stat_time_day"]). + /// + public List Dimensions { get; set; } = new() { "campaign_id", "stat_time_day" }; + + /// + /// Metrics to query (e.g., ["spend", "impressions", "clicks", "cpc", "ctr"]). + /// + public List Metrics { get; set; } = new() { "spend", "impressions", "clicks", "cpc", "ctr", "cpm" }; + + /// Start date in format "YYYY-MM-DD". + public string? StartDate { get; set; } + + /// End date in format "YYYY-MM-DD". + public string? EndDate { get; set; } + + /// Use lifetime stats instead of date range. + public bool Lifetime { get; set; } = false; + + /// + /// Filters (e.g., [{"field_name":"campaign_ids","filter_type":"IN","filter_value":"[\"123\"]"}]). + /// + public List? Filters { get; set; } + + public int PageSize { get; set; } = 50; + public int Page { get; set; } = 1; +} + +public sealed class ReportFilter +{ + public string FieldName { get; set; } = string.Empty; + public string FilterType { get; set; } = "IN"; + public string FilterValue { get; set; } = string.Empty; +} + +#endregion + +#region Fund Management Payloads + +/// +/// Transfer funds to/from an advertiser account in the Business Center. +/// Endpoint: POST /bc/transfer/ +/// This is a unique TikTok feature — direct fund management via API. +/// +public sealed class TransferFundsPayload +{ + /// Target advertiser account ID. + public string AdvertiserId { get; set; } = string.Empty; + + /// Transfer type: "RECHARGE" (add funds) or "DEDUCT" (remove funds). + public string TransferType { get; set; } = "RECHARGE"; + + /// Amount to transfer in account currency. + public decimal Amount { get; set; } +} + +#endregion + +#region Enums + +/// +/// TikTok campaign objectives. +/// See: https://business-api.tiktok.com/portal/docs?id=1739381516454913 +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TikTokObjective +{ + /// REACH - Maximize ad reach + Reach = 0, + + /// TRAFFIC - Drive traffic to website/app + Traffic = 1, + + /// VIDEO_VIEWS - Maximize video views + VideoViews = 2, + + /// LEAD_GENERATION - Collect leads via instant forms + LeadGeneration = 3, + + /// COMMUNITY_INTERACTION - Grow followers and profile visits + CommunityInteraction = 4, + + /// APP_PROMOTION - Drive app installs/re-engagement + AppPromotion = 5, + + /// WEB_CONVERSIONS - Drive website conversions + WebConversions = 6, + + /// PRODUCT_SALES - Drive catalog/shop sales + ProductSales = 7 +} + +/// +/// TikTok campaign/ad group status values. +/// TikTok uses ENABLE/DISABLE rather than ACTIVE/PAUSED. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TikTokCampaignStatus +{ + /// ENABLE - Running/active + Enable = 0, + + /// DISABLE - Paused + Disable = 1, + + /// DELETE - Soft-deleted + Delete = 2 +} + +/// +/// TikTok budget modes. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TikTokBudgetMode +{ + /// BUDGET_MODE_DAY - Daily budget + Day = 0, + + /// BUDGET_MODE_TOTAL - Lifetime/total budget + Total = 1, + + /// BUDGET_MODE_INFINITE - No budget limit (budget at ad group level) + Infinite = 2 +} + +/// +/// TikTok billing events for ad groups. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TikTokBillingEvent +{ + CPC = 0, + CPM = 1, + CPV = 2, + OCPM = 3 +} + +/// +/// TikTok optimization goals for ad groups. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TikTokOptimizationGoal +{ + Click = 0, + Impression = 1, + Reach = 2, + VideoView = 3, + Landing_Page_View = 4, + Lead_Generation = 5, + Convert = 6, + Install = 7, + Value = 8 +} + +#endregion diff --git a/TikTokApi/Models/ProviderModels.cs b/TikTokApi/Models/ProviderModels.cs new file mode 100644 index 0000000..4a2f823 --- /dev/null +++ b/TikTokApi/Models/ProviderModels.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TikTokApi.Models; + +/// +/// Request from Gateway to TikTokApi. +/// Identical contract to GoogleApi/MetaApi ProviderRequest for Gateway compatibility. +/// +public sealed class ProviderRequest +{ + /// + /// Operation to execute (e.g., "Ping", "CreateCampaign", "GetCampaignInsights") + /// + public string Operation { get; set; } = string.Empty; + + /// + /// Tenant/account ID - maps to TikTok advertiser_id (ad account ID). + /// Populated by Gateway from tbAdAccount.accExternalAccountId where accNetwork='tiktok'. + /// + public string? TenantId { get; set; } + + /// + /// Login customer ID - maps to TikTok Business Center ID. + /// In TikTok's agency model, the BC owns and manages client advertiser accounts. + /// Populated by Gateway from tbAdAccount.accLoginAccountId. + /// + public string? LoginCustomerId { get; set; } + + /// + /// Correlation ID for request tracing. + /// + public string? RequestId { get; set; } + + /// + /// Operation-specific payload. + /// + public JsonElement? Payload { get; set; } + + /// + /// Deserialize payload to strongly-typed object. + /// + public T GetPayload() where T : new() + { + if (Payload == null || Payload.Value.ValueKind == JsonValueKind.Null || Payload.Value.ValueKind == JsonValueKind.Undefined) + return new T(); + + try + { + return JsonSerializer.Deserialize(Payload.Value.GetRawText(), JsonOptions.Default) ?? new T(); + } + catch + { + return new T(); + } + } +} + +/// +/// Response from TikTokApi to Gateway. +/// Identical contract to GoogleApi/MetaApi ProviderResponse. +/// +public sealed class ProviderResponse +{ + public bool Ok { get; set; } + public string? RequestId { get; set; } + public object? Data { get; set; } + public ProviderError? Error { get; set; } + + public static ProviderResponse Success(string? requestId, object? data = null) + => new() { Ok = true, RequestId = requestId, Data = data }; + + public static ProviderResponse Fail(string? requestId, string code, string message, object? detail = null) + => new() + { + Ok = false, + RequestId = requestId, + Error = new ProviderError { Code = code, Message = message, Detail = detail } + }; +} + +public sealed class ProviderError +{ + public string Code { get; set; } = "ERROR"; + public string Message { get; set; } = "Unknown error"; + public object? Detail { get; set; } +} + +internal static class JsonOptions +{ + public static readonly JsonSerializerOptions Default = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} diff --git a/TikTokApi/Program.cs b/TikTokApi/Program.cs new file mode 100644 index 0000000..89abf7e --- /dev/null +++ b/TikTokApi/Program.cs @@ -0,0 +1,66 @@ +using TikTokApi.Configuration; +using TikTokApi.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Port binding (same pattern as GoogleApi/MetaApi) +var port = Environment.GetEnvironmentVariable("PORT") ?? "5400"; +builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); + +// Configuration +builder.Services.Configure(options => +{ + // Bind from appsettings TikTok section + builder.Configuration.GetSection("TikTok").Bind(options); + + // Environment variable overrides (Azure Container Apps pattern) + options.AppId = Environment.GetEnvironmentVariable("TikTok__AppId") ?? options.AppId; + options.AppSecret = Environment.GetEnvironmentVariable("TikTok__AppSecret") ?? options.AppSecret; + options.AccessToken = Environment.GetEnvironmentVariable("TikTok__AccessToken") ?? options.AccessToken; + options.BusinessCenterId = Environment.GetEnvironmentVariable("TikTok__BusinessCenterId") ?? options.BusinessCenterId; + options.ApiVersion = Environment.GetEnvironmentVariable("TikTok__ApiVersion") ?? options.ApiVersion; + options.ApiBaseUrl = Environment.GetEnvironmentVariable("TikTok__ApiBaseUrl") ?? options.ApiBaseUrl; + + var enableReal = Environment.GetEnvironmentVariable("TikTok__EnableRealApi"); + if (bool.TryParse(enableReal, out var realApi)) + options.EnableRealApi = realApi; +}); + +// HTTP client for TikTok Marketing API +builder.Services.AddHttpClient(client => +{ + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Controllers + Swagger +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new() { Title = "TikTokApi", Version = "v1" }); +}); + +var app = builder.Build(); + +// Swagger (all environments - same as GoogleApi/MetaApi) +app.UseSwagger(); +app.UseSwaggerUI(); + +app.MapControllers(); + +app.MapGet("/", () => Results.Ok(new +{ + service = "TikTokApi", + status = "running", + version = "1.0.0", + timestamp = DateTimeOffset.UtcNow +})); + +app.Logger.LogInformation("TikTokApi starting on port {Port}", port); + +app.Run(); diff --git a/TikTokApi/Properties/launchSettings.json b/TikTokApi/Properties/launchSettings.json new file mode 100644 index 0000000..7dc8a02 --- /dev/null +++ b/TikTokApi/Properties/launchSettings.json @@ -0,0 +1,52 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5205" + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7202;http://localhost:5205" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Container (.NET SDK)": { + "commandName": "SdkContainer", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14543", + "sslPort": 44335 + } + } +} \ No newline at end of file diff --git a/TikTokApi/README.md b/TikTokApi/README.md new file mode 100644 index 0000000..ab790cf --- /dev/null +++ b/TikTokApi/README.md @@ -0,0 +1,299 @@ +# TikTokApi - TikTok Marketing API Provider Service + +Standalone microservice for TikTok advertising integration. Mirrors the GoogleApi/MetaApi architecture — the Gateway routes `provider="tiktok"` requests here via internal HTTP. + +## Architecture + +``` +Gateway ──(POST /internal/execute)──► TikTokApi ──(REST)──► TikTok Marketing API + X-Internal-Key auth │ business-api.tiktok.com + ├── Emulated mode (default) + └── Real API mode (TikTok__EnableRealApi=true) +``` + +## Platform Comparison + +| Aspect | GoogleApi | MetaApi | TikTokApi | +|--------|-----------|---------|-----------| +| Parent entity | MCC (Manager) | Business Manager | **Business Center (BC)** | +| Child accounts | Customer ID | Ad Account (`act_XXX`) | **Advertiser ID** (numeric) | +| Auth | OAuth refresh tokens | System User token | **OAuth → long-lived token** | +| Token expiry | Requires refresh | Never (if user active) | **Never (unless revoked)** | +| SDK | Google.Ads NuGet | HttpClient (Graph API) | **HttpClient (REST)** | +| Auth header | OAuth Bearer | query param | **`Access-Token` header** | +| Base URL | via SDK | graph.facebook.com | **business-api.tiktok.com** | +| API versioning | SDK version | URL path (`/v21.0/`) | **URL path (`/v1.3/`)** | +| Hierarchy | Campaign→Ad Group→Ad | Campaign→Ad Set→Ad | **Campaign→Ad Group→Ad** | +| Status values | ENABLED/PAUSED | ACTIVE/PAUSED | **ENABLE/DISABLE** | +| Response format | gRPC via SDK | JSON `{data}` | **JSON `{code,message,data}`** | +| Fund management | N/A | N/A | **BC Transfer API** | + +## Operations + +| Operation | Description | Endpoint | Real API | +|-----------|-------------|----------|----------| +| Ping / TestPing | Health check | N/A | N/A | +| CreateCampaign | Create campaign | POST /campaign/create/ | ✅ | +| GetCampaign | Retrieve campaign | GET /campaign/get/ | ✅ | +| UpdateCampaign | Update name/budget | POST /campaign/update/ | ✅ | +| UpdateCampaignStatus | Enable/disable/delete | POST /campaign/status/update/ | ✅ | +| ListCampaigns | List with filters | GET /campaign/get/ | ✅ | +| GetReport | Performance metrics | POST /report/integrated/get/ | ✅ | +| CreateAdvertiser | Create ad account under BC | POST /bc/advertiser/create | ✅ | +| ListAdvertisers | List BC ad accounts | GET /bc/advertiser/get | ✅ | +| TransferFunds | Recharge/deduct ad account | POST /bc/transfer/ | ✅ | + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `TIKTOK_INTERNAL_KEY` | Yes | Gateway→TikTokApi shared auth key | +| `TikTok__EnableRealApi` | No | `false` (default) = emulated responses | +| `TikTok__AppId` | For real API | App ID from TikTok Developer Portal | +| `TikTok__AppSecret` | For real API | App Secret from Developer Portal | +| `TikTok__AccessToken` | For real API | Long-lived token from OAuth flow | +| `TikTok__BusinessCenterId` | For real API | USIM's Business Center numeric ID | +| `TikTok__ApiVersion` | No | API version (default: `v1.3`) | +| `TikTok__ApiBaseUrl` | No | `https://business-api.tiktok.com` (prod) or `https://sandbox-ads.tiktok.com` (sandbox) | + +--- + +## TikTok Business Center & API Setup + +### Phase 1: Business Center Setup + +1. **Create TikTok For Business Account** + - Go to https://www.tiktok.com/business/ + - Sign up with a business email (use USIM company email) + - Complete business verification + +2. **Create Business Center** + - Go to https://business.tiktok.com/ + - Click "Create Business Center" + - Choose **Agency** type (not Advertiser — this is critical for managing client accounts) + - Enter USIM business details, complete verification + - Note the **Business Center ID** (numeric, visible in the URL) + +3. **Verify Business** + - Business Center → Settings → Verification + - Upload required business documentation + - Verification typically takes 1-3 business days + - **Required before creating ad accounts programmatically** + +### Phase 2: Developer App Registration + +4. **Register as Developer** + - Go to https://business-api.tiktok.com/portal + - Click "Become a Developer" (top right) + - Fill in developer application: + - Company name: USIM + - Website: your company URL + - Use case description: "Agency platform for managing client TikTok advertising campaigns programmatically via API. Creating and managing advertiser accounts, campaigns, ad groups, ads, and pulling performance reports." + - Submit and wait for approval (usually 1-2 business days) + +5. **Create App** + - My Apps → Create New + - **App Name**: "USIM AdPlatform" (or similar) + - **Description**: "Multi-channel advertising management platform for SMB clients" + - **Advertiser Redirect URL**: `https://adptestapi.usimdev.com/auth/tiktok/callback` (for OAuth flow) + - **Scope of Permissions** — select all that apply: + - ✅ Ad Account Management (`ad_account_management`) + - ✅ Campaign Management (`campaign_management`) + - ✅ Ad Management (`ad_management`) + - ✅ Creative Management (`creative_management`) + - ✅ Reporting (`reporting`) + - ✅ Audience Management (`audience_management`) + - ✅ Business Center Management (`bc_management`) + - Note your **App ID** and **App Secret** + +6. **App Review** + - After creating the app, it goes through TikTok review + - Basic access is granted immediately (sandbox) + - For production access to campaign management, you may need additional review + +### Phase 3: Access Token Generation + +7. **Authorize the App** + - Navigate to the Authorization URL (found in app details): + ``` + https://business-api.tiktok.com/portal/auth?app_id={APP_ID}&state=usim&redirect_uri={REDIRECT_URI} + ``` + - Log in with the TikTok Business account that owns the Business Center + - Grant all requested permissions + - **DO NOT close the redirect page** — copy the `auth_code` from the URL + +8. **Exchange Auth Code for Access Token** + ```bash + curl -X POST "https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/" \ + -H "Content-Type: application/json" \ + -d '{ + "app_id": "YOUR_APP_ID", + "secret": "YOUR_APP_SECRET", + "auth_code": "AUTH_CODE_FROM_REDIRECT" + }' + ``` + + Response: + ```json + { + "code": 0, + "message": "OK", + "data": { + "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "advertiser_ids": ["7123456789012345678", "7123456789012345679"] + } + } + ``` + + **Important**: The access token does NOT expire unless the advertiser revokes authorization. + Store it securely in Azure Key Vault. + +9. **Verify Token Works** + ```bash + curl "https://business-api.tiktok.com/open_api/v1.3/oauth2/advertiser/get/?app_id={APP_ID}&secret={APP_SECRET}" \ + -H "Access-Token: YOUR_ACCESS_TOKEN" + ``` + This should return the list of advertiser IDs you have access to. + +### Phase 4: Sandbox Testing (Recommended) + +10. **Use Sandbox Environment First** + - Sandbox URL: `https://sandbox-ads.tiktok.com` + - Set `TikTok__ApiBaseUrl=https://sandbox-ads.tiktok.com` in env vars + - Sandbox uses the same endpoints but with test data + - Create test campaigns, verify response formats + - Switch to production URL when ready + +### Phase 5: Deploy TikTokApi Container + +11. **Configure Azure Container Apps** + ```bash + # Create container + az containerapp create \ + --name usim-adp-tiktokapi \ + --resource-group USIM-AdPlatform \ + --environment usim-adp-env \ + --image /usim-adp-tiktokapi:latest \ + --target-port 5400 \ + --ingress internal \ + --env-vars \ + TIKTOK_INTERNAL_KEY=secretref:tiktok-internal-key \ + TikTok__EnableRealApi=false + + # Add secrets (when ready for real API) + az containerapp secret set \ + --name usim-adp-tiktokapi \ + --resource-group USIM-AdPlatform \ + --secrets \ + tiktok-access-token= \ + tiktok-app-secret= + ``` + +12. **Configure Gateway** + - Add environment variables to Gateway container: + ``` + TIKTOK_PROVIDER_URL=https://usim-adp-tiktokapi.internal..azurecontainerapps.io + TIKTOK_INTERNAL_KEY= + ``` + - Gateway's `ExecutionService.GetProviderUrl()` routes `provider="tiktok"` to `TIKTOK_PROVIDER_URL` + +### Phase 6: Database Setup + +13. **Add TikTok channel references** (if not already present) + - Ensure `tbChannelConfig` has `tiktok` channel entry + - Create `vwTikTokAccount` view (mirrors vwGoogleAccount pattern) + - Create `vwTikTokCampaign` view + - Create `spTikTokAccount` stored procedure for account linking + +14. **Gateway MultiChannel config** + - Verify `appsettings.json` MultiChannel section includes tiktok with StatusMappings: + ```json + { + "tiktok": { + "StatusMappings": { + "ENABLE": "active", + "DISABLE": "paused", + "DELETE": "cancelled" + } + } + } + ``` + +--- + +## TikTok API Quick Reference + +### Response Envelope +All TikTok Marketing API responses follow this format: +```json +{ + "code": 0, + "message": "OK", + "data": { ... }, + "request_id": "202502171234567890..." +} +``` +- `code: 0` = success +- Non-zero = error (e.g., 40001 = auth error, 40002 = permission denied) + +### Key Endpoint Patterns +``` +GET /open_api/v1.3/campaign/get/?advertiser_id=XXX +POST /open_api/v1.3/campaign/create/ (JSON body with advertiser_id) +POST /open_api/v1.3/campaign/update/ (JSON body) +POST /open_api/v1.3/campaign/status/update/ (separate from update) +POST /open_api/v1.3/report/integrated/get/ (reporting) +POST /open_api/v1.3/bc/advertiser/create (BC operations) +POST /open_api/v1.3/bc/transfer/ (fund management) +``` + +### Common Error Codes +| Code | Meaning | +|------|---------| +| 0 | Success | +| 40001 | Authentication error (invalid/expired token) | +| 40002 | No permission for this operation | +| 40100 | Invalid parameter | +| 40700 | Rate limit exceeded | +| 50000 | Internal server error | + +--- + +## Project Structure + +``` +TikTokApi/ +├── Configuration/ +│ └── TikTokConfig.cs # Config + TikTokApiContext +├── Controllers/ +│ └── InternalController.cs # /internal/execute + /internal/health +├── Models/ +│ ├── OperationPayloads.cs # TikTok-specific payloads + enums +│ └── ProviderModels.cs # ProviderRequest/Response (Gateway contract) +├── Security/ +│ └── InternalAuthFilter.cs # X-Internal-Key validation +├── Services/ +│ ├── TikTokApiClient.cs # HTTP wrapper for business-api.tiktok.com +│ └── TikTokMarketingService.cs # Operation dispatcher (emulated/real) +├── Program.cs +├── appsettings.json +└── TikTokApi.csproj +``` + +## Local Development + +```bash +dotnet run --project TikTokApi +# → http://localhost:5400 +# → Swagger: http://localhost:5400/swagger +``` + +## Port Assignments + +| Service | Port | +|---------|------| +| Gateway | 5000 | +| GoogleApi | 5200 | +| MetaApi | 5300 | +| **TikTokApi** | **5400** | +| Creative | 5100 | diff --git a/TikTokApi/Security/InternalAuthFilter.cs b/TikTokApi/Security/InternalAuthFilter.cs new file mode 100644 index 0000000..b04b427 --- /dev/null +++ b/TikTokApi/Security/InternalAuthFilter.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace TikTokApi.Security; + +/// +/// Validates the X-Internal-Key header for internal service-to-service calls. +/// Gateway must provide the correct key to call TikTokApi endpoints. +/// +public sealed class InternalAuthFilter : IAsyncActionFilter +{ + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public InternalAuthFilter(IConfiguration config, ILogger logger) + { + _config = config; + _logger = logger; + } + + public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var headerName = _config["InternalAuth:HeaderName"] ?? "X-Internal-Key"; + + // Try multiple sources for the key + var expectedKey = _config["InternalAuth:Key"] + ?? _config["TIKTOK_INTERNAL_KEY"] + ?? Environment.GetEnvironmentVariable("TIKTOK_INTERNAL_KEY"); + + if (string.IsNullOrWhiteSpace(expectedKey)) + { + _logger.LogError("[InternalAuth] No internal key configured - check TIKTOK_INTERNAL_KEY env var"); + context.Result = new ObjectResult(new { error = "Internal auth key not configured" }) + { + StatusCode = 500 + }; + return Task.CompletedTask; + } + + if (!context.HttpContext.Request.Headers.TryGetValue(headerName, out var providedKey) || + string.IsNullOrWhiteSpace(providedKey)) + { + _logger.LogWarning("[InternalAuth] Missing {HeaderName} header", headerName); + context.Result = new UnauthorizedObjectResult(new { error = $"Missing {headerName} header" }); + return Task.CompletedTask; + } + + if (!string.Equals(providedKey.ToString(), expectedKey, StringComparison.Ordinal)) + { + _logger.LogWarning("[InternalAuth] Invalid key provided"); + context.Result = new UnauthorizedObjectResult(new { error = "Invalid internal auth key" }); + return Task.CompletedTask; + } + + _logger.LogDebug("[InternalAuth] Request authorized"); + return next(); + } +} diff --git a/TikTokApi/Services/TikTokApiClient.cs b/TikTokApi/Services/TikTokApiClient.cs new file mode 100644 index 0000000..9b1b351 --- /dev/null +++ b/TikTokApi/Services/TikTokApiClient.cs @@ -0,0 +1,257 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using TikTokApi.Configuration; +using Microsoft.Extensions.Options; + +namespace TikTokApi.Services; + +/// +/// HTTP wrapper for TikTok Marketing API calls. +/// Handles authentication, API versioning, error parsing, and response envelope unwrapping. +/// +/// TikTok Marketing API pattern: +/// Base: https://business-api.tiktok.com/open_api/{version}/{endpoint} +/// Auth: Access-Token header (NOT query param, NOT Bearer) +/// Request: JSON body for POST, query params for GET +/// Response envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."} +/// Code 0 = success, anything else = error +/// +/// Sandbox: https://sandbox-ads.tiktok.com/open_api/{version}/{endpoint} +/// +public sealed class TikTokApiClient +{ + private readonly HttpClient _http; + private readonly TikTokConfig _config; + private readonly ILogger _logger; + + public TikTokApiClient(HttpClient http, IOptions config, ILogger logger) + { + _http = http; + _config = config.Value; + _logger = logger; + + var baseUrl = _config.ApiBaseUrl.TrimEnd('/'); + _http.BaseAddress = new Uri($"{baseUrl}/open_api/{_config.ApiVersion}/"); + _http.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds); + } + + public bool IsRealApiEnabled => _config.EnableRealApi + && !string.IsNullOrWhiteSpace(_config.AccessToken); + + // ================================================================ + // GET - for read operations (campaign/get, advertiser/info, etc.) + // ================================================================ + + /// + /// GET request to TikTok Marketing API. + /// TikTok GET endpoints use query parameters. + /// + public async Task GetAsync( + string endpoint, Dictionary? queryParams = null, CancellationToken ct = default) + { + var url = BuildUrl(endpoint, queryParams); + var safeUrl = SanitizeForLogging(url); + + _logger.LogDebug("[TikTokApi] GET {Url}", safeUrl); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + InjectAuth(request); + + var response = await _http.SendAsync(request, ct); + return await ParseResponseAsync(response, safeUrl, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + _logger.LogWarning("[TikTokApi] GET {Url} timed out", safeUrl); + return TikTokApiResponse.Error("Request timed out", -1); + } + catch (Exception ex) + { + _logger.LogError(ex, "[TikTokApi] GET {Url} failed", safeUrl); + return TikTokApiResponse.Error(ex.Message, -1); + } + } + + // ================================================================ + // POST - for write operations (campaign/create, campaign/update, etc.) + // ================================================================ + + /// + /// POST request to TikTok Marketing API. + /// TikTok POST endpoints accept JSON body. + /// + public async Task PostAsync( + string endpoint, object body, CancellationToken ct = default) + { + var safeEndpoint = SanitizeForLogging(endpoint); + _logger.LogDebug("[TikTokApi] POST {Endpoint}", safeEndpoint); + + try + { + var json = JsonSerializer.Serialize(body, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + InjectAuth(request); + + var response = await _http.SendAsync(request, ct); + return await ParseResponseAsync(response, safeEndpoint, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + _logger.LogWarning("[TikTokApi] POST {Endpoint} timed out", safeEndpoint); + return TikTokApiResponse.Error("Request timed out", -1); + } + catch (Exception ex) + { + _logger.LogError(ex, "[TikTokApi] POST {Endpoint} failed", safeEndpoint); + return TikTokApiResponse.Error(ex.Message, -1); + } + } + + // ================================================================ + // Response parsing + // ================================================================ + + /// + /// Parse TikTok response envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."} + /// Code 0 = success, anything else = error. + /// + private async Task ParseResponseAsync( + HttpResponseMessage response, string context, CancellationToken ct) + { + var body = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("[TikTokApi] HTTP {StatusCode} from {Context}: {Body}", + (int)response.StatusCode, context, Truncate(body, 500)); + } + + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + var code = root.TryGetProperty("code", out var codeProp) ? codeProp.GetInt32() : -1; + var message = root.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null; + var requestId = root.TryGetProperty("request_id", out var ridProp) ? ridProp.GetString() : null; + + if (code == 0) + { + // Success - extract data + JsonElement? data = root.TryGetProperty("data", out var dataProp) ? dataProp.Clone() : null; + + _logger.LogDebug("[TikTokApi] Success from {Context} | RequestId={RequestId}", + context, requestId); + + return new TikTokApiResponse + { + IsSuccess = true, + Code = 0, + Message = message ?? "OK", + Data = data, + TikTokRequestId = requestId + }; + } + else + { + // Error + _logger.LogWarning( + "[TikTokApi] Error from {Context} | Code={Code} Message={Message} RequestId={RequestId}", + context, code, message, requestId); + + return new TikTokApiResponse + { + IsSuccess = false, + Code = code, + Message = message ?? "Unknown error", + TikTokRequestId = requestId + }; + } + } + catch (JsonException ex) + { + _logger.LogError(ex, "[TikTokApi] Failed to parse response from {Context}: {Body}", + context, Truncate(body, 300)); + return TikTokApiResponse.Error($"Invalid JSON response: {ex.Message}", -1); + } + } + + // ================================================================ + // Helpers + // ================================================================ + + /// + /// Inject Access-Token header. TikTok uses a custom header name, NOT "Authorization: Bearer". + /// + private void InjectAuth(HttpRequestMessage request) + { + if (!string.IsNullOrWhiteSpace(_config.AccessToken)) + { + request.Headers.TryAddWithoutValidation("Access-Token", _config.AccessToken); + } + } + + private static string BuildUrl(string endpoint, Dictionary? queryParams) + { + if (queryParams == null || queryParams.Count == 0) + return endpoint; + + var sb = new StringBuilder(endpoint); + sb.Append('?'); + var first = true; + foreach (var (key, value) in queryParams) + { + if (!first) sb.Append('&'); + sb.Append(Uri.EscapeDataString(key)); + sb.Append('='); + sb.Append(Uri.EscapeDataString(value)); + first = false; + } + return sb.ToString(); + } + + /// + /// Strip tokens/secrets from URLs for safe logging. + /// + private static string SanitizeForLogging(string input) + { + // TikTok doesn't put tokens in URLs (they're in headers), but sanitize just in case + return input; + } + + private static string Truncate(string text, int maxLength) + => text.Length <= maxLength ? text : text[..maxLength] + "..."; +} + +/// +/// Parsed TikTok API response. +/// TikTok envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."} +/// +public sealed class TikTokApiResponse +{ + public bool IsSuccess { get; set; } + + /// TikTok error code. 0 = success. + public int Code { get; set; } + + /// Human-readable message from TikTok. + public string? Message { get; set; } + + /// Response data payload (when successful). + public JsonElement? Data { get; set; } + + /// TikTok-assigned request ID for support debugging. + public string? TikTokRequestId { get; set; } + + public static TikTokApiResponse Error(string message, int code) + => new() { IsSuccess = false, Code = code, Message = message }; +} diff --git a/TikTokApi/Services/TikTokMarketingService.cs b/TikTokApi/Services/TikTokMarketingService.cs new file mode 100644 index 0000000..fc91ad5 --- /dev/null +++ b/TikTokApi/Services/TikTokMarketingService.cs @@ -0,0 +1,641 @@ +using System.Text.Json; +using TikTokApi.Configuration; +using TikTokApi.Models; +using Microsoft.Extensions.Options; + +namespace TikTokApi.Services; + +/// +/// Core service for TikTok Marketing API operations. +/// Follows the same dual-mode pattern as GoogleAdsService / MetaMarketingService: +/// - When EnableRealApi=false: returns emulated responses +/// - When EnableRealApi=true: makes real Marketing API calls +/// +/// TikTok Marketing API endpoints: +/// Campaign: /campaign/create/, /campaign/get/, /campaign/update/, /campaign/status/update/ +/// Ad Group: /adgroup/create/, /adgroup/get/, /adgroup/update/ +/// Report: /report/integrated/get/ +/// BC: /bc/advertiser/create, /bc/advertiser/get, /bc/transfer/ +/// +public sealed class TikTokMarketingService +{ + private readonly TikTokConfig _config; + private readonly TikTokApiClient _apiClient; + private readonly ILogger _logger; + + public TikTokMarketingService( + IOptions config, + TikTokApiClient apiClient, + ILogger logger) + { + _config = config.Value; + _apiClient = apiClient; + _logger = logger; + } + + public async Task ExecuteAsync(ProviderRequest request, CancellationToken ct) + { + var requestId = request.RequestId ?? Guid.NewGuid().ToString("N"); + var operation = (request.Operation ?? string.Empty).Trim(); + + _logger.LogInformation( + "[TikTokAds] Executing {Operation} | RequestId={RequestId} TenantId={TenantId} RealApi={RealApi}", + operation, requestId, request.TenantId, _apiClient.IsRealApiEnabled); + + try + { + var context = new TikTokApiContext + { + AdvertiserId = request.TenantId ?? string.Empty, + BusinessCenterId = request.LoginCustomerId ?? _config.BusinessCenterId + }; + + var result = operation switch + { + "Ping" => Ping(requestId), + "TestPing" => Ping(requestId), + + // Campaign operations + "CreateCampaign" => await CreateCampaignAsync(request, context, requestId, ct), + "GetCampaign" => await GetCampaignAsync(request, context, requestId, ct), + "UpdateCampaign" => await UpdateCampaignAsync(request, context, requestId, ct), + "ListCampaigns" => await ListCampaignsAsync(request, context, requestId, ct), + "UpdateCampaignStatus" => await UpdateCampaignStatusAsync(request, context, requestId, ct), + + // Reporting + "GetReport" => await GetReportAsync(request, context, requestId, ct), + + // Advertiser (ad account) management via Business Center + "CreateAdvertiser" => await CreateAdvertiserAsync(request, context, requestId, ct), + "ListAdvertisers" => await ListAdvertisersAsync(context, requestId, ct), + + // Fund management (BC-specific) + "TransferFunds" => await TransferFundsAsync(request, context, requestId, ct), + + "" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"), + _ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}") + }; + + _logger.LogInformation( + "[TikTokAds] Completed {Operation} | RequestId={RequestId} Ok={Ok}", + operation, requestId, result.Ok); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "[TikTokAds] Error in {Operation} | RequestId={RequestId}", operation, requestId); + return ProviderResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message); + } + } + + // ================================================================ + // Ping + // ================================================================ + + private ProviderResponse Ping(string requestId) + => ProviderResponse.Success(requestId, new + { + message = "TikTokApi provider is healthy", + service = "TikTokApi", + realApiEnabled = _apiClient.IsRealApiEnabled, + apiVersion = _config.ApiVersion, + businessCenterId = _config.BusinessCenterId, + timestamp = DateTimeOffset.UtcNow + }); + + // ================================================================ + // Campaign Operations + // ================================================================ + + private async Task CreateCampaignAsync( + ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.Name)) + return ProviderResponse.Fail(requestId, "VALIDATION", "Campaign name is required"); + + if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId)) + return await CreateCampaignRealAsync(payload, context, requestId, ct); + + // Emulated + var emulatedId = GenerateId().ToString(); + return ProviderResponse.Success(requestId, new + { + campaignId = emulatedId, + name = payload.Name, + objective = MapObjectiveToApi(payload.Objective), + status = MapStatusToApi(payload.Status), + budgetMode = MapBudgetModeToApi(payload.BudgetMode), + budget = payload.Budget, + advertiserId = context.AdvertiserId, + emulated = true + }); + } + + private async Task CreateCampaignRealAsync( + CreateCampaignPayload payload, TikTokApiContext context, string requestId, CancellationToken ct) + { + // POST /campaign/create/ + var body = new Dictionary + { + ["advertiser_id"] = context.AdvertiserId, + ["campaign_name"] = payload.Name, + ["objective_type"] = MapObjectiveToApi(payload.Objective), + ["budget_mode"] = MapBudgetModeToApi(payload.BudgetMode), + ["operation_status"] = MapStatusToApi(payload.Status) + }; + + if (payload.Budget.HasValue) + body["budget"] = payload.Budget.Value; + + if (payload.SpecialIndustries.Count > 0) + body["special_industries"] = payload.SpecialIndustries; + + var result = await _apiClient.PostAsync("campaign/create/", body, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR", + result.Message ?? "Failed to create campaign", + new { tikTokCode = result.Code, tikTokRequestId = result.TikTokRequestId }); + + var campaignId = result.Data?.TryGetProperty("campaign_id", out var idProp) == true + ? idProp.GetString() : null; + + return ProviderResponse.Success(requestId, new + { + campaignId, + name = payload.Name, + objective = MapObjectiveToApi(payload.Objective), + advertiserId = context.AdvertiserId, + emulated = false + }); + } + + private async Task GetCampaignAsync( + ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.CampaignId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"); + + if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId)) + { + // GET /campaign/get/ with filtering + var queryParams = new Dictionary + { + ["advertiser_id"] = context.AdvertiserId, + ["filtering"] = JsonSerializer.Serialize(new + { + campaign_ids = new[] { payload.CampaignId } + }) + }; + + var result = await _apiClient.GetAsync("campaign/get/", queryParams, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR", + result.Message ?? "Failed to get campaign"); + + return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false }); + } + + // Emulated + return ProviderResponse.Success(requestId, new + { + campaignId = payload.CampaignId, + name = $"Emulated Campaign {payload.CampaignId}", + objectiveType = "TRAFFIC", + operationStatus = "DISABLE", + budgetMode = "BUDGET_MODE_DAY", + budget = 50.00m, + createTime = DateTimeOffset.UtcNow.AddDays(-7).ToString("o"), + modifyTime = DateTimeOffset.UtcNow.ToString("o"), + emulated = true + }); + } + + private async Task UpdateCampaignAsync( + ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.CampaignId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"); + + if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId)) + { + // POST /campaign/update/ + var body = new Dictionary + { + ["advertiser_id"] = context.AdvertiserId, + ["campaign_id"] = payload.CampaignId + }; + + if (!string.IsNullOrWhiteSpace(payload.Name)) + body["campaign_name"] = payload.Name; + + if (payload.Budget.HasValue) + body["budget"] = payload.Budget.Value; + + var result = await _apiClient.PostAsync("campaign/update/", body, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR", + result.Message ?? "Failed to update campaign"); + + return ProviderResponse.Success(requestId, new + { + campaignId = payload.CampaignId, + updated = true, + emulated = false + }); + } + + // Emulated + return ProviderResponse.Success(requestId, new + { + campaignId = payload.CampaignId, + updated = true, + name = payload.Name, + emulated = true + }); + } + + private async Task UpdateCampaignStatusAsync( + ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.CampaignId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"); + + if (!payload.Status.HasValue) + return ProviderResponse.Fail(requestId, "VALIDATION", "Status is required"); + + if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId)) + { + // POST /campaign/status/update/ (separate endpoint from /campaign/update/) + var body = new Dictionary + { + ["advertiser_id"] = context.AdvertiserId, + ["campaign_ids"] = new[] { payload.CampaignId }, + ["operation_status"] = MapStatusToApi(payload.Status.Value) + }; + + var result = await _apiClient.PostAsync("campaign/status/update/", body, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR", + result.Message ?? "Failed to update campaign status"); + + return ProviderResponse.Success(requestId, new + { + campaignId = payload.CampaignId, + status = MapStatusToApi(payload.Status.Value), + emulated = false + }); + } + + // Emulated + return ProviderResponse.Success(requestId, new + { + campaignId = payload.CampaignId, + status = MapStatusToApi(payload.Status.Value), + emulated = true + }); + } + + private async Task ListCampaignsAsync( + ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId)) + { + var queryParams = new Dictionary + { + ["advertiser_id"] = context.AdvertiserId, + ["page_size"] = payload.PageSize.ToString(), + ["page"] = payload.Page.ToString() + }; + + if (payload.StatusFilter.HasValue) + { + queryParams["filtering"] = JsonSerializer.Serialize(new + { + operation_status = MapStatusToApi(payload.StatusFilter.Value) + }); + } + + var result = await _apiClient.GetAsync("campaign/get/", queryParams, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR", + result.Message ?? "Failed to list campaigns"); + + return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false }); + } + + // Emulated + var campaigns = Enumerable.Range(1, 3).Select(i => new + { + campaign_id = GenerateId().ToString(), + campaign_name = $"Emulated Campaign {i}", + objective_type = "TRAFFIC", + operation_status = i == 1 ? "ENABLE" : "DISABLE", + budget_mode = "BUDGET_MODE_DAY", + budget = 50.00m * i, + create_time = DateTimeOffset.UtcNow.AddDays(-i * 7).ToString("o") + }); + + return ProviderResponse.Success(requestId, new + { + campaigns, + advertiserId = context.AdvertiserId, + pageInfo = new { page = 1, pageSize = 50, totalNumber = 3, totalPage = 1 }, + emulated = true + }); + } + + // ================================================================ + // Reporting + // ================================================================ + + private async Task GetReportAsync( + ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (_apiClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdvertiserId)) + { + // POST /report/integrated/get/ + var body = new Dictionary + { + ["advertiser_id"] = context.AdvertiserId, + ["report_type"] = payload.ReportType, + ["data_level"] = payload.DataLevel, + ["dimensions"] = payload.Dimensions, + ["metrics"] = payload.Metrics, + ["page_size"] = payload.PageSize, + ["page"] = payload.Page, + ["lifetime"] = payload.Lifetime + }; + + if (!string.IsNullOrWhiteSpace(payload.StartDate)) + body["start_date"] = payload.StartDate; + if (!string.IsNullOrWhiteSpace(payload.EndDate)) + body["end_date"] = payload.EndDate; + if (payload.Filters?.Count > 0) + body["filters"] = payload.Filters; + + var result = await _apiClient.PostAsync("report/integrated/get/", body, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR", + result.Message ?? "Failed to get report"); + + return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false }); + } + + // Emulated report data + var rng = new Random(); + var rows = Enumerable.Range(0, 7).Select(i => + { + var date = DateTime.UtcNow.Date.AddDays(-i); + var impressions = rng.Next(2000, 80000); + var clicks = rng.Next(100, impressions / 8); + var spend = Math.Round(clicks * (rng.NextDouble() * 1.5 + 0.3), 2); + return new + { + dimensions = new { stat_time_day = date.ToString("yyyy-MM-dd"), campaign_id = GenerateId().ToString() }, + metrics = new + { + spend = spend.ToString("F2"), + impressions = impressions.ToString(), + clicks = clicks.ToString(), + cpc = (spend / clicks).ToString("F2"), + ctr = (clicks * 100.0 / impressions).ToString("F2"), + cpm = (spend / impressions * 1000).ToString("F2") + } + }; + }).Reverse(); + + return ProviderResponse.Success(requestId, new + { + list = rows, + pageInfo = new { page = 1, pageSize = 50, totalNumber = 7, totalPage = 1 }, + emulated = true + }); + } + + // ================================================================ + // Advertiser (Ad Account) Management via Business Center + // ================================================================ + + private async Task CreateAdvertiserAsync( + ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.Name)) + return ProviderResponse.Fail(requestId, "VALIDATION", "Advertiser name is required"); + + if (string.IsNullOrWhiteSpace(context.BusinessCenterId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessCenterId is required"); + + if (_apiClient.IsRealApiEnabled) + { + // POST /bc/advertiser/create + var body = new Dictionary + { + ["bc_id"] = context.BusinessCenterId, + ["advertiser_name"] = payload.Name, + ["currency"] = payload.Currency, + ["timezone"] = payload.Timezone, + ["company"] = payload.Company + }; + + if (!string.IsNullOrWhiteSpace(payload.IndustryId)) + body["industry_id"] = payload.IndustryId; + if (!string.IsNullOrWhiteSpace(payload.ContactEmail)) + body["contact_email"] = payload.ContactEmail; + if (!string.IsNullOrWhiteSpace(payload.ContactPhone)) + body["contact_phone"] = payload.ContactPhone; + + var result = await _apiClient.PostAsync("bc/advertiser/create", body, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR", + result.Message ?? "Failed to create advertiser", + new { tikTokCode = result.Code, tikTokRequestId = result.TikTokRequestId }); + + var advertiserId = result.Data?.TryGetProperty("advertiser_id", out var idProp) == true + ? idProp.GetString() : null; + + return ProviderResponse.Success(requestId, new + { + advertiserId, + name = payload.Name, + currency = payload.Currency, + businessCenterId = context.BusinessCenterId, + emulated = false + }); + } + + // Emulated + return ProviderResponse.Success(requestId, new + { + advertiserId = GenerateId().ToString(), + name = payload.Name, + currency = payload.Currency, + timezone = payload.Timezone, + businessCenterId = context.BusinessCenterId, + status = "STATUS_ENABLE", + emulated = true + }); + } + + private async Task ListAdvertisersAsync( + TikTokApiContext context, string requestId, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(context.BusinessCenterId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessCenterId is required"); + + if (_apiClient.IsRealApiEnabled) + { + // GET /bc/advertiser/get + var queryParams = new Dictionary + { + ["bc_id"] = context.BusinessCenterId, + ["page_size"] = "100", + ["page"] = "1" + }; + + var result = await _apiClient.GetAsync("bc/advertiser/get", queryParams, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR", + result.Message ?? "Failed to list advertisers"); + + return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false }); + } + + // Emulated + var advertisers = Enumerable.Range(1, 3).Select(i => new + { + advertiser_id = GenerateId().ToString(), + advertiser_name = $"Client Account {i}", + status = "STATUS_ENABLE", + currency = "USD", + timezone = "America/Los_Angeles", + balance = (i * 500.00m).ToString("F2") + }); + + return ProviderResponse.Success(requestId, new + { + advertisers, + businessCenterId = context.BusinessCenterId, + emulated = true + }); + } + + // ================================================================ + // Fund Management (Business Center) + // ================================================================ + + private async Task TransferFundsAsync( + ProviderRequest request, TikTokApiContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + if (string.IsNullOrWhiteSpace(payload.AdvertiserId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "AdvertiserId is required"); + + if (string.IsNullOrWhiteSpace(context.BusinessCenterId)) + return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessCenterId is required"); + + if (payload.Amount <= 0) + return ProviderResponse.Fail(requestId, "VALIDATION", "Amount must be greater than zero"); + + if (_apiClient.IsRealApiEnabled) + { + // POST /bc/transfer/ + var body = new Dictionary + { + ["bc_id"] = context.BusinessCenterId, + ["advertiser_id"] = payload.AdvertiserId, + ["transfer_type"] = payload.TransferType, + ["cash_amount"] = payload.Amount + }; + + var result = await _apiClient.PostAsync("bc/transfer/", body, ct); + + if (!result.IsSuccess) + return ProviderResponse.Fail(requestId, "TIKTOK_API_ERROR", + result.Message ?? "Failed to transfer funds"); + + return ProviderResponse.Success(requestId, new + { + advertiserId = payload.AdvertiserId, + transferType = payload.TransferType, + amount = payload.Amount, + emulated = false + }); + } + + // Emulated + return ProviderResponse.Success(requestId, new + { + advertiserId = payload.AdvertiserId, + transferType = payload.TransferType, + amount = payload.Amount, + balanceAfter = payload.TransferType == "RECHARGE" ? 1500.00m : 500.00m, + emulated = true + }); + } + + // ================================================================ + // Helpers + // ================================================================ + + /// + /// Map platform objective enum to TikTok API string. + /// + private static string MapObjectiveToApi(TikTokObjective objective) => objective switch + { + TikTokObjective.Reach => "REACH", + TikTokObjective.Traffic => "TRAFFIC", + TikTokObjective.VideoViews => "VIDEO_VIEWS", + TikTokObjective.LeadGeneration => "LEAD_GENERATION", + TikTokObjective.CommunityInteraction => "COMMUNITY_INTERACTION", + TikTokObjective.AppPromotion => "APP_PROMOTION", + TikTokObjective.WebConversions => "WEB_CONVERSIONS", + TikTokObjective.ProductSales => "PRODUCT_SALES", + _ => "TRAFFIC" + }; + + /// + /// Map platform status enum to TikTok API string. + /// TikTok uses ENABLE/DISABLE, not ACTIVE/PAUSED. + /// + private static string MapStatusToApi(TikTokCampaignStatus status) => status switch + { + TikTokCampaignStatus.Enable => "ENABLE", + TikTokCampaignStatus.Disable => "DISABLE", + TikTokCampaignStatus.Delete => "DELETE", + _ => "DISABLE" + }; + + private static string MapBudgetModeToApi(TikTokBudgetMode mode) => mode switch + { + TikTokBudgetMode.Day => "BUDGET_MODE_DAY", + TikTokBudgetMode.Total => "BUDGET_MODE_TOTAL", + TikTokBudgetMode.Infinite => "BUDGET_MODE_INFINITE", + _ => "BUDGET_MODE_DAY" + }; + + private static long GenerateId() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000 + Random.Shared.Next(999); +} diff --git a/TikTokApi/TikTokApi.csproj b/TikTokApi/TikTokApi.csproj new file mode 100644 index 0000000..d55f17f --- /dev/null +++ b/TikTokApi/TikTokApi.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + True + mcr.microsoft.com/dotnet/aspnet:8.0 + tiktokapi + + + + + + + + + + + + diff --git a/TikTokApi/TikTokApi.http b/TikTokApi/TikTokApi.http new file mode 100644 index 0000000..9c39bce --- /dev/null +++ b/TikTokApi/TikTokApi.http @@ -0,0 +1,6 @@ +@TikTokApi_HostAddress = http://localhost:5205 + +GET {{TikTokApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/TikTokApi/appsettings.Development.json b/TikTokApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/TikTokApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/TikTokApi/appsettings.json b/TikTokApi/appsettings.json new file mode 100644 index 0000000..231c903 --- /dev/null +++ b/TikTokApi/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "TikTok": { + "AppId": "", + "AppSecret": "", + "AccessToken": "", + "BusinessCenterId": "", + "ApiVersion": "v1.3", + "EnableRealApi": false, + "ApiBaseUrl": "https://business-api.tiktok.com", + "TimeoutSeconds": 30 + }, + "InternalKey": "dev-tiktok-internal-key-change-in-production" +}