using Dapr; using EnablementBLL.ShowSpace; using EnablementDAL.DTO; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.PubSub; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using static GB5Shared.GB5Constant.Constant; namespace EnablementSL.Subscriptions; // eventlog-topic -> force-subscribe every newly-created Employee into the "Onboarding" // MSHOWSPACE. Recruitment/PayRoll Phase 3 hire-to-Enablement bridge (see // /Users/venkatv/.claude/plans/can-you-check-the-precious-sunbeam.md). // // PayRollBLL.Employee.EmployeeBLL.SaveEmployee publishes EventTypeConstant. // SAVEEMPLOYEEEVENTTYPEID on Dapr topic "eventlog-topic" (PayRollSL/HRFinanceHost) via // GB5Shared.EventLogPublish.PublishEventLogAsync, after the new Employee row (and, for // EmployeeIsUser == 0, its paired MUSER account) is saved. EnablementSL runs under a // different host (EngagementHost) with zero direct DI/BLL access to PayRoll, so this Dapr // subscription is the only viable integration path — confirmed no other module currently // subscribes to "eventlog-topic" other than FrameworkSL's generic // EventLogSubscriberService (which persists every event verbatim; Dapr fans this same // topic out to every subscribed endpoint independently, so adding this one is additive // and does not affect that one). // // Payload shape — confirmed by reading GB5Shared.EventLogPublish.PublishEventLogAsync / // PublishEventsAsync directly, and cross-checked against FrameworkSL's own // EventLogSubscriberService.cs (the one existing real consumer of this exact topic) and // EntitlementSL's ClientProvisioningExecutedSubscriber/ClientProvisioningProgressSubscriber // (whose own code comments independently document the identical finding): publishing via // PublishEventLogAsync wraps the real EventLogDTO (EventText/EventTypeId/DataId/Data/...) // inside a PublishDTO envelope's Request property — PublishEventsAsync only ever populates // PublishDTO's own top-level EventTypeId/DataId/Data/etc. fields with their .NET defaults // (-1/null), NEVER the real values, because it reflects EventLogDTO's properties (minus // "Login") into Request instead. So the real EventTypeId/Data live at // publishDTO.Request.EventTypeId / .Data, not publishdto.EventTypeId / .Data — and Data // itself is a double-JSON-encoded string (EmployeeDTO serialized once by EventLogPublish, // then the whole envelope serialized again for the wire). Binds the raw body as // [FromBody] string, exactly like EventLogSubscriberService.cs, rather than a typed model, // because ASP.NET's default model binder cannot resolve this nested/double-encoded shape. // // EmployeeId -> UserId: no lookup needed. EmployeeBLL.SaveOrUpdateEmployeeUserAccountAsync // (confirmed by reading EmployeeBLL.cs directly) only creates/keeps a MUSER row when // EmployeeDTO.EmployeeIsUser == 0 ("0 = Yes"), and always assigns UserId = // EmployeeDTO.EmployeeId when it does. An employee saved with EmployeeIsUser != 0 has no // user account at all, so there is nothing to subscribe — that case is skipped here. // // Onboarding SpaceId: NOT hardcoded. MSHOWSPACE.SPACEID for the seeded 'ONBOARDING' row // (see 20260903_Enablement_OnboardingSpace_Seed_{SqlServer,Postgres}.sql) is reserved // dynamically from MAUTONUMBER at migration time, so it is environment-specific and can't // safely be a compile-time constant. Resolved per-event instead via // IShowSpaceSubscriptionBLL.GetMySpacesAsync (an in-process call — same host, same // EngagementHost DI container — so its ResponseStandardDTO.Body is the real // List reference, not a serialized string), matched on the stable // OnboardingSpaceCode below. This mirrors the "resolve seeded master data by its stable // code, never a hardcoded numeric id" convention this repo's own // 20260817_Enablement_ModuleSeed / 20260818_Enablement_ShowSpaceMenuSeed migrations // already use for MODULECODE/MENUCODE lookups. // // Failure handling: filtering out events that are not ours (wrong EventTypeId, unparseable // envelope, EmployeeIsUser != 0, Onboarding Space not yet seeded in this environment) is // NOT a failure — those return 200 so Dapr never retries a message this handler correctly // decided isn't for it. A genuine processing exception (e.g. ForceSubscriptionAsync itself // throwing) returns 500, the same retryable choice TmsSkillGrantSubscriber and // EntitlementSL's ClientProvisioningExecutedSubscriber/ClientProvisioningProgressSubscriber // all make for their own Dapr subscriptions — never crashes the process, never silently // swallows a real failure. [ApiController] public class EmployeeOnboardedSubscriber : ControllerBase { private const string ONBOARDING_SPACE_CODE = "ONBOARDING"; private readonly IShowSpaceSubscriptionBLL _showSpaceSubscriptionBLL; private readonly ILogger _logger; public EmployeeOnboardedSubscriber( IShowSpaceSubscriptionBLL showSpaceSubscriptionBLL, ILogger logger) { _showSpaceSubscriptionBLL = showSpaceSubscriptionBLL; _logger = logger; } [Topic("pubsub", "eventlog-topic")] [HttpPost("/Enablement/Subscriptions/EmployeeOnboarded")] public async Task HandleAsync([FromBody] string jsonMessage, CancellationToken ct) { if (string.IsNullOrEmpty(jsonMessage)) return Ok(); PublishDTO? publishDto; try { publishDto = JsonConvert.DeserializeObject(jsonMessage); } catch (JsonException ex) { // Not every "eventlog-topic" publisher necessarily uses PublishEventLogAsync's exact // envelope shape — an unparseable message here is "not ours", not a failure to retry. _logger.LogDebug(ex, "EmployeeOnboardedSubscriber could not parse eventlog-topic message as PublishDTO; ignoring."); return Ok(); } if (publishDto?.Request is not JObject requestObj) return Ok(); int eventTypeId = requestObj.Value("EventTypeId") ?? -1; if (eventTypeId != EventTypeConstant.SAVEEMPLOYEEEVENTTYPEID) return Ok(); // shared topic — many unrelated event types pass through here try { string? employeeJson = requestObj.Value("Data"); if (string.IsNullOrEmpty(employeeJson)) { _logger.LogWarning("eventlog-topic SAVEEMPLOYEEEVENTTYPEID event carried no Data payload; skipping."); return Ok(); } var employee = JsonConvert.DeserializeObject(employeeJson); if (employee is null || employee.EmployeeId == 0) { _logger.LogWarning("eventlog-topic SAVEEMPLOYEEEVENTTYPEID event Data did not parse to a valid EmployeeId; skipping."); return Ok(); } if (employee.EmployeeIsUser != 0) return Ok(); // no MUSER account was created for this employee — nothing to subscribe var login = (publishDto.Login as JObject)?.ToObject(); if (login is null) { _logger.LogWarning( "eventlog-topic SAVEEMPLOYEEEVENTTYPEID event for EmployeeId {EmployeeId} carried no usable Login context; skipping.", employee.EmployeeId); return Ok(); } var spacesResponse = await _showSpaceSubscriptionBLL.GetMySpacesAsync(login, ct).ConfigureAwait(false); var onboardingSpace = (spacesResponse.Body as List) ?.FirstOrDefault(s => string.Equals(s.SpaceCode, ONBOARDING_SPACE_CODE, StringComparison.OrdinalIgnoreCase)); if (onboardingSpace is null) { _logger.LogWarning( "Onboarding Space (SpaceCode={SpaceCode}) not found for EmployeeId {EmployeeId} — has 20260903_Enablement_OnboardingSpace_Seed been applied to this environment? Skipping force-subscribe.", ONBOARDING_SPACE_CODE, employee.EmployeeId); return Ok(); } var forceDto = new ForceSpaceSubscriptionDTO { SpaceId = onboardingSpace.SpaceId, UserId = employee.EmployeeId, // UserId == EmployeeId for EmployeeIsUser == 0 (see class remarks) IsSubscribed = true, IsLocked = false }; await _showSpaceSubscriptionBLL.ForceSubscriptionAsync(forceDto, login, ct).ConfigureAwait(false); _logger.LogInformation( "Force-subscribed new employee UserId {UserId} into Onboarding Space {SpaceId}.", employee.EmployeeId, onboardingSpace.SpaceId); return Ok(); } catch (Exception ex) { _logger.LogError(ex, "EmployeeOnboardedSubscriber failed to force-subscribe an employee into the Onboarding Space."); return StatusCode(500, ex.Message); } } } // Minimal projection of PayRollDAL.DTO.Employee.EmployeeDTO's JSON shape — deliberately not a // reference to the real EmployeeDTO (PayRoll is a different module/host; EnablementSL has no // project reference to it and must not take one just for this). Newtonsoft matches by property // name, so only the two fields this subscriber actually needs are declared; every other field in // the real EmployeeDTO's JSON is ignored. internal sealed class EmployeeOnboardedPayload { public int EmployeeId { get; set; } public byte EmployeeIsUser { get; set; } }