using EntitlementBLL.Common; using EntitlementBLL.Exceptions; using EntitlementBLL.Interfaces; using EntitlementBLL.Options; using EntitlementDAL.DTOs; using EntitlementDAL.Interfaces; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.Telemetry; using GB5Shared.User; using Microsoft.Extensions.Options; using static GB5Shared.GB5Constant.Constant; namespace EntitlementBLL.Implementations; // Thread 1 §2.2, narrowed to MCLIENT/MUSER creation only — the one true, code-only blocker // confirmed to have zero precedent anywhere in this repo (no INSERT INTO MCLIENT/MUSER exists // today, HTTP-reachable or otherwise). MCLIENT/MUSER live in Entitlement's own shared platform // database (EntitlementDbOptions' own doc comment: MENTITLEMENT* tables carry real FK // constraints to MCLIENT/MUSER, which only works when both live in the same physical DB) — so // this needs no new cross-database wiring, just EntitlementLoginFactory's existing connection. // MUSER creation reuses the existing, already-validated IUserBLL.SaveUser rather than // reinventing user-creation logic — see this class's own plan section for the TENANTID gap // that reuse surfaced and fixed in UserQB.SAVE_USER/SAVE_USER_PG. public class ClientProvisioningBLL : IClientProvisioningBLL { private readonly IClientProvisioningDAL _ClientProvisioningDAL; private readonly IClientUserProvisioner _UserProvisioner; private readonly AutoNumber _AutoNumber; private readonly IEntitlementLoginFactory _LoginFactory; private readonly EventLogPublish _EventLog; private readonly ClientProvisioningOptions _Options; public ClientProvisioningBLL( IClientProvisioningDAL clientProvisioningDAL, IClientUserProvisioner userProvisioner, AutoNumber autoNumber, IEntitlementLoginFactory loginFactory, EventLogPublish eventLog, IOptions options) { _ClientProvisioningDAL = clientProvisioningDAL; _UserProvisioner = userProvisioner; _AutoNumber = autoNumber; _LoginFactory = loginFactory; _EventLog = eventLog; _Options = options.Value; } public async Task CreateClientAsync(CreateClientRequestDTO req, CancellationToken ct) { if (req is null) throw new ArgumentNullException(nameof(req)); if (string.IsNullOrWhiteSpace(req.ClientCode)) throw new ArgumentException("ClientCode is required.", nameof(req)); if (string.IsNullOrWhiteSpace(req.ClientName)) throw new ArgumentException("ClientName is required.", nameof(req)); if (string.IsNullOrWhiteSpace(req.AdminUserCode)) throw new ArgumentException("AdminUserCode is required.", nameof(req)); if (string.IsNullOrWhiteSpace(req.AdminUserName)) throw new ArgumentException("AdminUserName is required.", nameof(req)); if (string.IsNullOrWhiteSpace(req.AdminEmail)) throw new ArgumentException("AdminEmail is required.", nameof(req)); if (string.IsNullOrWhiteSpace(req.AdminMobile)) throw new ArgumentException("AdminMobile is required.", nameof(req)); if (string.IsNullOrWhiteSpace(_Options.DefaultAdminRoleCode)) throw new InvalidOperationException( "Entitlement:ClientProvisioning:DefaultAdminRoleCode is not configured — set it to " + "the ROLECODE of a real MROLE row in the target EntitlementDb before creating a client."); // Platform-level operation, not scoped to any existing client — matches this codebase's // established -1 = NONE sentinel convention (e.g. MENTITLEMENTFEATURE.FEATUREGROUPID). var platformLogin = _LoginFactory.Create(-1); // Tracked outside the try so the catch block can roll it back if InsertClientAsync // fails after the CLIENT autonumber slot was already reserved — confirmed live (tracker // §31.13) that this was a real, not hypothetical, gap: a failed insert (from the // SourceType bug above) left the CLIENT counter permanently advanced with no MCLIENT row // to show for it. Mirrors the same RollbackAutoNumber pattern SyncGroupBLL.Save uses. GB5Shared.DTO.Framework.AutoNumber.AutoNumberDTO? clientAuto = null; try { GB5Trace.Step("validate-client-provisioning", new { req.ClientCode }); if (await _ClientProvisioningDAL.ClientCodeExistsAsync(req.ClientCode, platformLogin, ct).ConfigureAwait(false)) throw new ClientCodeAlreadyExistsException(req.ClientCode); // Resolved by RoleCode, not a hardcoded numeric config value — §31.12. var defaultAdminRoleId = await _ClientProvisioningDAL .GetRoleIdByCodeAsync(_Options.DefaultAdminRoleCode, platformLogin, ct).ConfigureAwait(false) ?? throw new InvalidOperationException( $"MROLE.ROLECODE '{_Options.DefaultAdminRoleCode}' was not found in the target " + "EntitlementDb — run 20260805_Entitlement_ClientAdminRole_Seed first."); var now = DateTime.UtcNow; clientAuto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.CLIENT, platformLogin).ConfigureAwait(false); var newClientId = clientAuto.StartNumber; var clientDto = new NewClientDTO { ClientId = newClientId, ClientCode = req.ClientCode, ClientName = req.ClientName, ClientShortName = string.IsNullOrWhiteSpace(req.ClientShortName) ? req.ClientCode : req.ClientShortName, // Every client created through this pipeline today is a standard // multi-tenant SaaS deployment — no on-prem/BYOC provisioning path exists yet. // Tracker §52 — threads the caller's own choice through instead of hardcoding; // req.DeploymentType defaults to 0/SaaS for every existing caller (Trial/Demo // never set it), so this is behavior-preserving for them. DeploymentType = req.DeploymentType, SubscriptionStatus = 0, TrialMode = 0, JurisdictionCode = req.JurisdictionCode, Version = 0, Status = 1, SortOrder = 1, CreatedById = platformLogin.UserId, CreatedOn = now, ModifiedById = platformLogin.UserId, ModifiedOn = now, // CKMCLIENT_SOURCETYPE only allows 1-5 (confirmed live, tracker §31.13) — 0 // fails outright, meaning no client could ever have been created through this // pipeline until this was caught. 1 matches the same "system/framework-created" // convention already used elsewhere in this engagement (MROLE/MWEBFORM/MMENU). SourceType = 1, }; GB5Trace.Step("save-client", new { newClientId }); await _ClientProvisioningDAL.InsertClientAsync(clientDto, platformLogin, ct).ConfigureAwait(false); // Second LoginDTO — ClientId now set to the client just created, so IUserBLL.SaveUser // (via the TENANTID fix) scopes the new MUSER row to it correctly. var newClientLogin = _LoginFactory.Create(newClientId); var provisionRequest = new ProvisionClientUserRequest { UserCode = req.AdminUserCode, UserName = req.AdminUserName, UserPrimaryMail = req.AdminEmail, UserPrimaryMobile = req.AdminMobile, RoleId = defaultAdminRoleId, }; GB5Trace.Step("save-first-admin-user", new { newClientId }); var provisionResult = await _UserProvisioner.ProvisionAsync(provisionRequest, newClientLogin).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeConstant.CLIENTPROVISIONEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Client Provisioned", clientDto, EventTypeConstant.CLIENTPROVISIONEDEVENTTYPEID, newClientId, newClientLogin, ct: ct).ConfigureAwait(false); return new CreateClientResultDTO { ClientId = newClientId, UserId = provisionResult.UserId, TemporaryPassword = provisionResult.UserGeneratedPassword, }; } catch (ClientCodeAlreadyExistsException) { throw; } catch (Exception ex) { GB5Trace.MarkFailed("create-client-failed", ex); if (clientAuto != null) await _AutoNumber.RollbackAutoNumber(AUTONUMBERCONSTANT.CLIENT, clientAuto.StartNumber, platformLogin).ConfigureAwait(false); throw; } } }