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;
}
}