using System.ComponentModel.DataAnnotations; using System.Text.Json; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using PartnerBLL.ClientDetail; using PartnerBLL.ClientDomain; using PartnerBLL.Interfaces; using PartnerBLL.PartnerBrand; using PartnerBLL.PartnerProduct; using PartnerBLL.PartnerTerm; using PartnerBLL.PartnerWebhook; using PartnerDAL.DTO.ClientDetail; using PartnerDAL.DTO.PartnerSetup; namespace PartnerBLL.PartnerSetup { // Orchestrates a full white-labeling setup across EVERY table it touches, in the only // order that actually works given each table's foreign-key dependency on the last: // TPARTNER -> TPARTNERPRODUCT -> TPARTNERBRAND -> (MCLIENTDETAILS create+assign) // -> TPARTNERPRODUCTBRAND -> TPARTNERTERM (1..N) -> TPARTNERAPIKEY -> TCLIENTDOMAIN // -> TPARTNERWEBHOOK (1..N) // Each step reuses the SAME BLL/validation/DAL path as its individual Save* endpoint — // this does not duplicate any business logic, it only sequences existing calls and // threads the IDs each step returns into the next step's request. // // All-or-nothing by design: every one of the 9 tables above is required, and any step // failing throws immediately, aborting the whole call — there is no partial-success // path here. (Rows already committed by earlier steps in THIS call are not automatically // rolled back — each Save* runs its own independent DB transaction — so a failure here // can leave, e.g., a Partner/Product/Brand created with no ApiKey; the caller should // treat a thrown error as "retry the whole setup", not "some tables succeeded".) public class PartnerSetupBLL : IPartnerSetupBLL { private readonly IPartnerBLL _partnerBLL; private readonly IPartnerProductBLL _partnerProductBLL; private readonly IPartnerBrandBLL _partnerBrandBLL; private readonly IClientDetailBLL _clientDetailBLL; private readonly IPartnerTermBLL _partnerTermBLL; private readonly IPartnerApiKeyBLL _partnerApiKeyBLL; private readonly IClientDomainBLL _clientDomainBLL; private readonly IPartnerWebhookBLL _partnerWebhookBLL; private readonly ILogger _logger; public PartnerSetupBLL( IPartnerBLL partnerBLL, IPartnerProductBLL partnerProductBLL, IPartnerBrandBLL partnerBrandBLL, IClientDetailBLL clientDetailBLL, IPartnerTermBLL partnerTermBLL, IPartnerApiKeyBLL partnerApiKeyBLL, IClientDomainBLL clientDomainBLL, IPartnerWebhookBLL partnerWebhookBLL, ILogger logger) { _partnerBLL = partnerBLL; _partnerProductBLL = partnerProductBLL; _partnerBrandBLL = partnerBrandBLL; _clientDetailBLL = clientDetailBLL; _partnerTermBLL = partnerTermBLL; _partnerApiKeyBLL = partnerApiKeyBLL; _clientDomainBLL = clientDomainBLL; _partnerWebhookBLL = partnerWebhookBLL; _logger = logger; } // Every Save* BLL in this module returns " " — the id is // reliably the last whitespace-separated token. Throws rather than silently // returning 0 if a result ever doesn't match, since a wrong id would otherwise // propagate silently into every step that follows it. private static int ExtractTrailingId(string result, string stepName) { var token = result?.Trim().Split(' ').LastOrDefault(); if (token == null || !int.TryParse(token, out var id)) throw new Exception($"{stepName} did not return a parseable id (result: \"{result}\")."); return id; } public async Task SetupPartnerComplete(PartnerSetupDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); if (dto.Partner == null) throw new ValidationException("Partner is required."); if (dto.PartnerProduct == null) throw new ValidationException("PartnerProduct is required."); if (dto.PartnerBrand == null) throw new ValidationException("PartnerBrand is required."); if (dto.ClientId == 0) throw new ValidationException("ClientId is required."); if (dto.ClientSiteId == 0) throw new ValidationException("ClientSiteId is required."); if (dto.PartnerProductBrand == null) throw new ValidationException("PartnerProductBrand is required."); if (dto.PartnerTerms is not { Count: > 0 }) throw new ValidationException("At least one PartnerTerm is required."); if (dto.PartnerApiKey == null) throw new ValidationException("PartnerApiKey is required."); if (dto.ClientDomain == null) throw new ValidationException("ClientDomain is required."); if (dto.PartnerWebhooks is not { Count: > 0 }) throw new ValidationException("At least one PartnerWebhook is required."); var result = new PartnerSetupResultDTO(); try { // ── Table 3: TPARTNER ────────────────────────────────────────────── GB5Trace.Step("setup-partner-save-partner", new { dto.Partner.PartnerCode }); dto.Partner.PartnerId = 0; // this orchestrator only ever creates, never updates var partnerResult = await _partnerBLL.SavePartner(dto.Partner, loginDTO).ConfigureAwait(false); int partnerId = ExtractTrailingId(partnerResult, "SavePartner"); result.PartnerId = partnerId; result.Messages.Add($"Partner created: {partnerResult}"); // ── Table 4: TPARTNERPRODUCT ─────────────────────────────────────── dto.PartnerProduct.PartnerProductId = 0; dto.PartnerProduct.PartnerId = partnerId; var productResult = await _partnerProductBLL.SavePartnerProduct(dto.PartnerProduct, loginDTO).ConfigureAwait(false); int partnerProductId = ExtractTrailingId(productResult, "SavePartnerProduct"); result.PartnerProductId = partnerProductId; result.Messages.Add($"PartnerProduct created: {productResult}"); // ── Table 5: TPARTNERBRAND ───────────────────────────────────────── dto.PartnerBrand.PartnerBrandId = 0; dto.PartnerBrand.PartnerId = partnerId; var brandResult = await _partnerBrandBLL.SavePartnerBrand(dto.PartnerBrand, loginDTO).ConfigureAwait(false); int partnerBrandId = ExtractTrailingId(brandResult, "SavePartnerBrand"); result.PartnerBrandId = partnerBrandId; result.Messages.Add($"PartnerBrand created: {brandResult}"); // ── Tables 1/2: MCLIENTDETAILS create (if missing) + assign ──────── // MSERVERCONFIG itself is not created here — it must already exist // (via /lic/Onboarding.svc/OnboardClient), since that's core client // provisioning outside this module's ownership. await _clientDetailBLL.CreateClientDetail(dto.ClientId, dto.ClientSiteId).ConfigureAwait(false); result.ClientDetailCreatedOrExisted = true; var assignResult = await _clientDetailBLL.AssignPartnerProduct( new ClientDetailDTO { ClientId = dto.ClientId, ClientSiteId = dto.ClientSiteId, PartnerProductId = partnerProductId }, loginDTO).ConfigureAwait(false); result.PartnerProductAssigned = true; result.Messages.Add($"Client assigned to PartnerProduct: {assignResult}"); // ── Table 6: TPARTNERPRODUCTBRAND (required) ─────────────────────── dto.PartnerProductBrand.PartnerProductBrandId = 0; dto.PartnerProductBrand.PartnerProductId = partnerProductId; var ppbResult = await _partnerBrandBLL.SavePartnerProductBrand(dto.PartnerProductBrand, loginDTO).ConfigureAwait(false); result.PartnerProductBrandId = ExtractTrailingId(ppbResult, "SavePartnerProductBrand"); result.Messages.Add($"PartnerProductBrand created: {ppbResult}"); // ── Table 7: TPARTNERTERM (required, 1..N) ───────────────────────── result.PartnerTermIds = new List(); foreach (var term in dto.PartnerTerms) { term.PartnerTermId = 0; term.PartnerProductId = partnerProductId; var termResult = await _partnerTermBLL.SavePartnerTerm(term, loginDTO).ConfigureAwait(false); result.PartnerTermIds.Add(ExtractTrailingId(termResult, "SavePartnerTerm")); result.Messages.Add($"PartnerTerm '{term.TermKey}' created: {termResult}"); } // ── Table 9: TPARTNERAPIKEY (required) ───────────────────────────── dto.PartnerApiKey.ApiKeyId = 0; dto.PartnerApiKey.PartnerProductId = partnerProductId; var apiKeyResultJson = await _partnerApiKeyBLL.SavePartnerApiKey(dto.PartnerApiKey, loginDTO).ConfigureAwait(false); using (var doc = JsonDocument.Parse(apiKeyResultJson)) { result.PartnerApiKeyId = doc.RootElement.GetProperty("ApiKeyId").GetInt32(); } result.Messages.Add($"PartnerApiKey created: {apiKeyResultJson}"); // ── Table 10: TCLIENTDOMAIN (required) ───────────────────────────── dto.ClientDomain.DomainId = 0; dto.ClientDomain.ClientId = dto.ClientId; var domainResult = await _clientDomainBLL.SaveClientDomain(dto.ClientDomain, loginDTO).ConfigureAwait(false); result.ClientDomainId = ExtractTrailingId(domainResult, "SaveClientDomain"); result.Messages.Add($"ClientDomain created: {domainResult}"); // ── Table 11: TPARTNERWEBHOOK (required, 1..N) ───────────────────── // Each webhook needs a real EVENTTYPEID (FK to MEVENTTYPE) — callers must // supply an id that already exists there; this orchestrator does not create // event types, the same way it doesn't create MSERVERCONFIG or MFILE rows. result.PartnerWebhookIds = new List(); foreach (var webhook in dto.PartnerWebhooks) { webhook.WebhookId = 0; webhook.PartnerProductId = partnerProductId; var webhookResult = await _partnerWebhookBLL.SavePartnerWebhook(webhook, loginDTO).ConfigureAwait(false); result.PartnerWebhookIds.Add(ExtractTrailingId(webhookResult, "SavePartnerWebhook")); result.Messages.Add($"PartnerWebhook '{webhook.CallbackUrl}' created: {webhookResult}"); } return result; } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("setup-partner-complete-failed", ex); _logger.LogError(ex, "SetupPartnerComplete failed after: {Messages}", string.Join(" | ", result.Messages)); throw; } } } }