using GB5Shared.DTO.Framework.Login; using GB5Shared.Enums.Client; using GB5Shared.GenerateAutoNumber; using GB5Shared.Telemetry; using SwBLL.ClientDatabase; using SwBLL.Integration; using SwBLL.Provisioning; using SwBLL.UpgradePackage; using SwDAL.CustomCode.RolloutCampaign; using SwDAL.DTO.RolloutCampaign; using static GB5Shared.GB5Constant.Constant; namespace SwBLL.RolloutCampaign; /// Multi-target rollout orchestration (tracker Thread 6 §39). Delivery itself is /// entirely IProvisioningBLL.ProvisionClientDatabase, called once per resolved target — this /// class only resolves which targets, tracks per-target state, and skips redundant /// re-application. No ChangeRequest involved (see §39's own finding on why not). /// /// Target resolution is Entitlement-aware (tracker §40) — Gate A (subscription/support/ /// deployment-type eligibility) and Gate B (feature/module licensing for MetadataOrStandardData /// packages) both run before a resolved ClientDbId becomes a real RolloutTarget row. Schema /// packages skip Gate B entirely, per the user's own confirmed "schema always full" decision. public class RolloutCampaignBLL : IRolloutCampaignBLL { private readonly IRolloutCampaignDAL _DAL; private readonly IProvisioningBLL _ProvisioningBLL; private readonly IUpgradePackageBLL _UpgradePackageBLL; private readonly IClientDatabaseBLL _ClientDatabaseBLL; private readonly IEntitlementSubscriptionClient _EntitlementClient; private readonly AutoNumber _AutoNumber; public RolloutCampaignBLL( IRolloutCampaignDAL dal, IProvisioningBLL provisioningBLL, IUpgradePackageBLL upgradePackageBLL, IClientDatabaseBLL clientDatabaseBLL, IEntitlementSubscriptionClient entitlementClient, AutoNumber autoNumber) { _DAL = dal; _ProvisioningBLL = provisioningBLL; _UpgradePackageBLL = upgradePackageBLL; _ClientDatabaseBLL = clientDatabaseBLL; _EntitlementClient = entitlementClient; _AutoNumber = autoNumber; } public async Task CreateCampaignAsync(RolloutCampaignDTO dto, LoginDTO login, CancellationToken ct) { if (dto == null) throw new ArgumentNullException(nameof(dto)); if (dto.PackageId <= 0) throw new ArgumentException("PackageId is required.", nameof(dto)); if (dto.DbModelId <= 0) throw new ArgumentException("DbModelId is required.", nameof(dto)); if (dto.TargetScope == 1 && (dto.TargetServerId is null || dto.TargetServerId <= 0)) throw new ArgumentException("TargetServerId is required when TargetScope=AllOnServer.", nameof(dto)); if (dto.TargetScope == 2 && (dto.SelectedClientDbIds is null || dto.SelectedClientDbIds.Count == 0)) throw new ArgumentException("SelectedClientDbIds is required when TargetScope=SelectedList.", nameof(dto)); GB5Trace.Step("resolve-rollout-targets", new { dto.PackageId, dto.TargetScope }); IEnumerable targetClientDbIds = dto.TargetScope switch { 0 => await _DAL.ResolveTargetsAllForDbModel(dto.DbModelId, login, ct).ConfigureAwait(false), 1 => await _DAL.ResolveTargetsAllOnServer(dto.DbModelId, dto.TargetServerId!.Value, login, ct).ConfigureAwait(false), 2 => dto.SelectedClientDbIds!, _ => throw new ArgumentException($"Unsupported TargetScope {dto.TargetScope}.", nameof(dto)) }; var candidateIds = targetClientDbIds.Distinct().ToList(); if (candidateIds.Count == 0) throw new InvalidOperationException("No client databases resolved for this rollout scope — nothing to create."); var resolvedIds = await FilterEntitlementEligibleAsync(candidateIds, dto, login, ct).ConfigureAwait(false); if (resolvedIds.Count == 0) throw new InvalidOperationException("No entitlement-eligible client databases resolved for this rollout scope — nothing to create."); var now = DateTime.UtcNow; var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.SWROLLOUTCAMPAIGN, login).ConfigureAwait(false); dto.RolloutCampaignId = auto.StartNumber; dto.CampaignStatus = dto.ScheduledStartOn is DateTime start && start > now ? (byte)1 : (byte)0; // Scheduled : Draft dto.TenantId = login.ClientId; dto.CreatedById = login.UserId; dto.CreatedOn = now; dto.ModifiedById = login.UserId; dto.ModifiedOn = now; GB5Trace.Step("save-rollout-campaign", new { dto.RolloutCampaignId, TargetCount = resolvedIds.Count }); await _DAL.InsertCampaign(dto, login, ct).ConfigureAwait(false); var targetAuto = await _AutoNumber.GetNumberAsync(resolvedIds.Count, AUTONUMBERCONSTANT.SWROLLOUTTARGET, login).ConfigureAwait(false); int nextTargetId = targetAuto.StartNumber; foreach (var clientDbId in resolvedIds) { await _DAL.InsertTarget(new RolloutTargetDTO { RolloutTargetId = nextTargetId++, RolloutCampaignId = dto.RolloutCampaignId, ClientDbId = clientDbId, TargetStatus = dto.CampaignStatus == 1 ? (byte)1 : (byte)0, // Deferred : Pending DeferredUntil = dto.CampaignStatus == 1 ? dto.ScheduledStartOn : null, Version = 1, Status = 1, SortOrder = 9999, CreatedById = login.UserId, CreatedOn = now, ModifiedById = login.UserId, ModifiedOn = now, SourceType = 5, TenantId = login.ClientId }, login, ct).ConfigureAwait(false); } return dto.RolloutCampaignId; } public async Task GetCampaignStatusAsync(int rolloutCampaignId, LoginDTO login, CancellationToken ct) => await _DAL.GetCampaignStatusRollup(rolloutCampaignId, login, ct).ConfigureAwait(false) ?? throw new InvalidOperationException($"Rollout campaign {rolloutCampaignId} not found or has no targets."); public async Task> GetListAsync(LoginDTO login, CancellationToken ct) => await _DAL.GetList(login, ct).ConfigureAwait(false); public async Task> GetTargetsAsync(int rolloutCampaignId, LoginDTO login, CancellationToken ct) => await _DAL.GetTargetsForCampaign(rolloutCampaignId, login, ct).ConfigureAwait(false); public async Task DeferTargetAsync(int rolloutTargetId, DateTime deferredUntil, LoginDTO login, CancellationToken ct) { var target = await _DAL.GetTargetById(rolloutTargetId, login, ct).ConfigureAwait(false) ?? throw new InvalidOperationException($"Rollout target {rolloutTargetId} not found."); target.TargetStatus = 1; // Deferred target.DeferredUntil = deferredUntil; target.ModifiedById = login.UserId; target.ModifiedOn = DateTime.UtcNow; await _DAL.UpdateTargetStatus(target, login, ct).ConfigureAwait(false); } public async Task SkipTargetAsync(int rolloutTargetId, LoginDTO login, CancellationToken ct) { var target = await _DAL.GetTargetById(rolloutTargetId, login, ct).ConfigureAwait(false) ?? throw new InvalidOperationException($"Rollout target {rolloutTargetId} not found."); target.TargetStatus = 4; // Skipped target.DeferredUntil = null; target.ModifiedById = login.UserId; target.ModifiedOn = DateTime.UtcNow; await _DAL.UpdateTargetStatus(target, login, ct).ConfigureAwait(false); } public async Task MarkManuallyAppliedAsync(int rolloutTargetId, LoginDTO login, CancellationToken ct) { var target = await _DAL.GetTargetById(rolloutTargetId, login, ct).ConfigureAwait(false) ?? throw new InvalidOperationException($"Rollout target {rolloutTargetId} not found."); if (target.TargetStatus != 6) // AwaitingManualApply throw new InvalidOperationException( $"Rollout target {rolloutTargetId} is not AwaitingManualApply (current status: {target.TargetStatus})."); target.TargetStatus = 3; // Applied target.AppliedOn = DateTime.UtcNow; target.LastError = null; target.ModifiedById = login.UserId; target.ModifiedOn = DateTime.UtcNow; await _DAL.UpdateTargetStatus(target, login, ct).ConfigureAwait(false); // Same finalize check ProcessDueTargetsAsync applies after every status change — a // manual confirmation can be the very last thing a campaign was waiting on. var rollup = await _DAL.GetCampaignStatusRollup(target.RolloutCampaignId, login, ct).ConfigureAwait(false); if (rollup is not null && rollup.PendingCount == 0 && rollup.DeferredCount == 0 && rollup.AwaitingManualApplyCount == 0) { byte finalStatus = rollup.FailedCount > 0 ? (byte)4 : (byte)3; // PartiallyFailed : Completed await _DAL.UpdateCampaignStatus(target.RolloutCampaignId, finalStatus, login, ct).ConfigureAwait(false); } } public async Task ProcessDueTargetsAsync(LoginDTO login, CancellationToken ct) { var now = DateTime.UtcNow; var due = (await _DAL.GetDueTargets(now, login, ct).ConfigureAwait(false)).ToList(); int processed = 0; foreach (var target in due) { ct.ThrowIfCancellationRequested(); var campaign = await _DAL.GetById(target.RolloutCampaignId, login, ct).ConfigureAwait(false); if (campaign is null) continue; // orphaned target — shouldn't happen, skip defensively await _DAL.UpdateCampaignStatus(campaign.RolloutCampaignId, 2, login, ct).ConfigureAwait(false); // InProgress try { var clientDb = await _ClientDatabaseBLL.GetById(target.ClientDbId, login, ct).ConfigureAwait(false); // DeliveryMode=Offline (tracker §53) — this client database has no live connection // RolloutCampaignBLL can push through; the operator must run ExportOfflinePackage // + SwOfflineApply themselves. Never call ProvisionClientDatabase for one of these. if (clientDb?.DeliveryMode == 1) { target.TargetStatus = 6; // AwaitingManualApply target.LastError = null; } else { bool alreadyApplied = await _DAL.IsPackageAlreadyApplied(target.ClientDbId, campaign.PackageId, login, ct) .ConfigureAwait(false); if (!alreadyApplied) { GB5Trace.Step("rollout-apply-target", new { target.RolloutTargetId, target.ClientDbId, campaign.PackageId }); await _ProvisioningBLL.ProvisionClientDatabase(target.ClientDbId, campaign.PackageId, login, ct) .ConfigureAwait(false); } target.TargetStatus = 3; // Applied target.AppliedOn = now; target.LastError = null; } } catch (Exception ex) { GB5Trace.MarkFailed("rollout-apply-target-failed", ex); target.TargetStatus = 5; // Failed target.LastError = ex.Message; } target.ModifiedById = login.UserId; target.ModifiedOn = now; await _DAL.UpdateTargetStatus(target, login, ct).ConfigureAwait(false); processed++; // Roll up campaign status once no more of its own targets remain Pending/Deferred/ // AwaitingManualApply — the last of those means a real external action (an operator // running SwOfflineApply) is still outstanding, so the campaign can't be considered // Completed/PartiallyFailed yet even though the worker itself has nothing left to do. var rollup = await _DAL.GetCampaignStatusRollup(campaign.RolloutCampaignId, login, ct).ConfigureAwait(false); if (rollup is not null && rollup.PendingCount == 0 && rollup.DeferredCount == 0 && rollup.AwaitingManualApplyCount == 0) { byte finalStatus = rollup.FailedCount > 0 ? (byte)4 : (byte)3; // PartiallyFailed : Completed await _DAL.UpdateCampaignStatus(campaign.RolloutCampaignId, finalStatus, login, ct).ConfigureAwait(false); } } return processed; } // ── Entitlement-aware gating (tracker §40) ───────────────────────────── // // Gate A (client eligibility): subscription must be Active and either still within its // support window or within GracePeriodDays of it — otherwise the client's support contract // has lapsed and it should not receive a pushed update. AllForDbModel/AllOnServer (the // automatic "sweep everyone" scopes) additionally require DeploymentType=SaaS — Unisoft // operates that infrastructure, so an automatic push is reasonable there; on-prem/BYOC/ // Hybrid clients are never swept into an automatic scope, only ever reachable via an // explicit SelectedList campaign. A client Entitlement has no snapshot for at all is // excluded, not included-by-default — "cannot determine eligibility" is not "eligible." // // Gate B (content eligibility): only applies to MetadataOrStandardData packages with a // non-null RequiredFeatureCode. Schema packages are always delivered in full, per the // user's own confirmed "schema always full" decision, and skip this check entirely. private async Task> FilterEntitlementEligibleAsync( List candidateClientDbIds, RolloutCampaignDTO dto, LoginDTO login, CancellationToken ct) { var package = await _UpgradePackageBLL.GetById(dto.PackageId, login, ct).ConfigureAwait(false) ?? throw new ArgumentException($"UpgradePackage {dto.PackageId} not found.", nameof(dto)); bool isAutoScope = dto.TargetScope is 0 or 1; // AllForDbModel or AllOnServer bool gateBApplies = package.PackageContentType == (byte)SwDAL.Enums.PackageContentType.MetadataOrStandardData && !string.IsNullOrWhiteSpace(package.RequiredFeatureCode); var eligible = new List(); foreach (var clientDbId in candidateClientDbIds) { var clientDb = await _ClientDatabaseBLL.GetById(clientDbId, login, ct).ConfigureAwait(false); if (clientDb is null) continue; // defensive — shouldn't happen for a just-resolved id var snapshot = await _EntitlementClient.GetSnapshotAsync(clientDb.TenantId, login, ct).ConfigureAwait(false); if (snapshot is null) continue; // Entitlement has no record for this client — exclude, never include-by-default // Gate A — subscription/support. // EntitlementDAL.Enums.SubscriptionStatusEnum.Active = 1 (SwBLL has no project // reference to EntitlementDAL — cross-module rule, §31/§38/§40 — so the numeric // value is used directly, same convention as the RolloutTargetStatus/CampaignStatus // byte literals already used throughout this class). bool subscriptionActive = snapshot.SubscriptionStatus == 1; bool inSupport = DateTime.UtcNow <= snapshot.SupportValidTill.AddDays(snapshot.GracePeriodDays); if (!subscriptionActive || !inSupport) continue; // Gate A — deployment type, automatic scopes only. if (isAutoScope && snapshot.DeploymentType != (byte)ClientDeploymentType.SaaS) continue; // Gate B — feature/module licensing (MetadataOrStandardData packages only). if (gateBApplies && !snapshot.EnabledFeatureCodes.Contains(package.RequiredFeatureCode!, StringComparer.OrdinalIgnoreCase)) continue; eligible.Add(clientDbId); } return eligible; } }