using FLSBLL.Registration; using FLSBLL.Respondent; using FLSBLL.Session; using FLSDAL.DTO.Registration; using FLSDAL.DTO.Respondent; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Telemetry; using Newtonsoft.Json; using TMSBLL.AssessmentAttempt; using TMSDAL.CustomCode.Assessment; using TMSDAL.CustomCode.AssessmentFlsBridge; using TMSDAL.DTO.AssessmentAttempt; using TMSDAL.DTO.AssessmentFlsBridge; using TMSDAL.DTO.AttemptResponse; namespace TMSBLL.AssessmentFlsBridge { public class AssessmentFlsCoordinatorBLL : IAssessmentFlsCoordinatorBLL { private readonly IAssessmentFlsBridgeDAL _BridgeDAL; private readonly IFlsRegistrationBLL _FlsRegistrationBLL; private readonly IFlsRespondentBLL _FlsRespondentBLL; private readonly IFlsSessionBLL _FlsSessionBLL; private readonly IAssessmentAttemptBLL _AssessmentAttemptBLL; private readonly IAssessmentDAL _AssessmentDAL; private readonly AutoNumber _AutoNumber; public AssessmentFlsCoordinatorBLL( IAssessmentFlsBridgeDAL bridgeDAL, IFlsRegistrationBLL flsRegistrationBLL, IFlsRespondentBLL flsRespondentBLL, IFlsSessionBLL flsSessionBLL, IAssessmentAttemptBLL assessmentAttemptBLL, IAssessmentDAL assessmentDAL, AutoNumber autoNumber) { _BridgeDAL = bridgeDAL; _FlsRegistrationBLL = flsRegistrationBLL; _FlsRespondentBLL = flsRespondentBLL; _FlsSessionBLL = flsSessionBLL; _AssessmentAttemptBLL = assessmentAttemptBLL; _AssessmentDAL = assessmentDAL; _AutoNumber = autoNumber; } // Lazily provisions one shared coordinator Registration+Instance+Group per tenant on // first use. Deliberately bypasses FlsInstanceBLL.CreateInstanceAsync/ScheduleInstanceAsync/ // OpenInstanceAsync — confirmed live that this 3-step Create/Schedule/Open path has no // existing caller anywhere and needs a scheduled OpensFrom/ClosesOn window this // always-open coordinator instance doesn't need; inserts the Instance/Group directly // instead, already Open, matching TmsFeedbackBLL's own proven raw-SQL precedent. private async Task EnsureCoordinatorAsync(LoginDTO loginDTO, CancellationToken ct) { var existing = await _BridgeDAL.GetCoordinatorByTenant(loginDTO, ct).ConfigureAwait(false); if (existing != null) return existing; GB5Trace.Step("provision-tms-fls-coordinator", new { loginDTO.ClientId }); // FORMTEMPLATEID for 'TMS_FLS_COORDINATOR' — seeded by migration // 20260815_TMS_FLS_Coordinator_Setup.sql. If this lookup ever returns nothing, the // migration hasn't been applied (or the MMODULE 'TMS' MODULECODE lookup inside it // failed) — flagged as a genuine live-verification dependency. int formTemplateId = await ResolveFormTemplateIdAsync(loginDTO, ct).ConfigureAwait(false); int moduleId = await _BridgeDAL.ResolveModuleIdByCode("TRAINING", loginDTO, ct).ConfigureAwait(false); var regResult = await _FlsRegistrationBLL.CreateRegistrationAsync(new CreateFlsRegistrationRequest( FlsRegistrationId: 0, FormTemplateId: formTemplateId, // MFLSREGISTRATION.REGISTRATIONCODE is NVARCHAR(20) — the original // "TMS_ASSESSMENT_COORD_{ClientId}" form overflows it for any real (large negative) // ClientId, failing with "String or binary data would be truncated" on first use. RegistrationCode: $"TMSCOORD{loginDTO.ClientId}", RegistrationName: "TMS Assessment Coordinator", ModuleId: moduleId, // MMODULE.MODULECODE = 'TRAINING' — confirmed live CompletionMethod: 4, // ExternalModule AllowDraftResponse: 0, AllowMultiAttempt: 1, IsMandatory: 0, TokenExpiryHours: 168, // 7 days TriggeredBy: 0, DefaultEipTemplCode: null, WebhookPath: null, ReminderRules: new List() ), loginDTO, ct).ConfigureAwait(false); int flsRegistrationId = int.Parse(regResult.Split(' ').Last()); var instanceAutoNum = await _AutoNumber.GetNumberAsync(1, "FLSINSTANCE", loginDTO).ConfigureAwait(false); int flsInstanceId = instanceAutoNum.StartNumber; await _BridgeDAL.InsertFlsInstanceOpen(flsInstanceId, flsRegistrationId, $"TMSCOORD-{loginDTO.ClientId}", "TMS Assessment Coordinator Instance", loginDTO, ct).ConfigureAwait(false); var groupAutoNum = await _AutoNumber.GetNumberAsync(1, "FLSINSTANCEGROUP", loginDTO).ConfigureAwait(false); int groupId = groupAutoNum.StartNumber; await _BridgeDAL.InsertFlsInstanceGroup(groupId, flsInstanceId, "DEFAULT", "Default Group", loginDTO, ct).ConfigureAwait(false); var coordinator = new AssessmentFlsCoordinatorDTO { FlsRegistrationId = flsRegistrationId, FlsInstanceId = flsInstanceId, GroupId = groupId, TenantId = loginDTO.ClientId }; await _BridgeDAL.SaveCoordinator(coordinator, loginDTO, ct).ConfigureAwait(false); return coordinator; } private async Task ResolveFormTemplateIdAsync(LoginDTO loginDTO, CancellationToken ct) { int result = await _BridgeDAL.ResolveFormTemplateIdByCode("TMS_FLS_COORDINATOR", loginDTO, ct).ConfigureAwait(false); if (result <= 0) throw new InvalidOperationException("MFORMTEMPLATE seed row 'TMS_FLS_COORDINATOR' not found — apply migration 20260815_TMS_FLS_Coordinator_Setup.sql first."); return result; } public async Task PrepareFlsAttemptAsync(PrepareFlsAttemptDTO dto, LoginDTO loginDTO, CancellationToken ct) { var coordinator = await EnsureCoordinatorAsync(loginDTO, ct).ConfigureAwait(false); GB5Trace.Step("dispatch-fls-assessment-link", new { dto.AssessmentId, dto.EnrollmentId }); var added = await _FlsRespondentBLL.AddRespondentsAsync(new AddRespondentsRequest( FlsInstanceId: coordinator.FlsInstanceId, GroupId: coordinator.GroupId, StepNo: 1, Respondents: new List { new RespondentRequest( RespondentType: 0, RespondentRefId: dto.EnrollmentId, SourceObjectTypeId: GB5Shared.GB5Constant.Constant.EntityConstant.OBJECTSESSIONASSESSMENT, SourceObjectId: dto.AssessmentId, SourceBizTypeId: -1, SourceReference: null, RespondentName: dto.RespondentName, RespondentMail: dto.RespondentEmail, RespondentMobile: null) }), loginDTO, ct).ConfigureAwait(false); var newRespondent = added.SingleOrDefault(); if (newRespondent == null) throw new InvalidOperationException("FLS respondent was not created — dispatch link unavailable."); await _BridgeDAL.SaveBridge(new AssessmentFlsBridgeDTO { AssessmentId = dto.AssessmentId, EnrollmentId = dto.EnrollmentId, FlsRespondentId = newRespondent.FlsRespondentId, AssessmentAttemptId = -1, DeliveryStatus = 0, TenantId = loginDTO.ClientId }, loginDTO, ct).ConfigureAwait(false); return new PrepareFlsAttemptResultDTO { AccessToken = newRespondent.AccessToken, TokenExpiry = newRespondent.TokenExpiry }; } public async Task GetFlsAttemptAsync(string token, CancellationToken ct) { if (!Guid.TryParse(token, out var accessToken)) return JsonConvert.SerializeObject(new { Error = "INVALID_TOKEN" }); // Cross-tenant, pre-authentication lookup — mirrors FlsRespondentDAL.GetByTokenAsync's // own ClientId=0/UserId=-1 precedent. var crossTenantLogin = new LoginDTO { ClientId = 0, UserId = -1 }; var respondentJson = await _FlsRespondentBLL.GetByTokenAsync(accessToken, crossTenantLogin, ct).ConfigureAwait(false); var respondent = JsonConvert.DeserializeObject(respondentJson); if (respondent == null || respondent.FlsRespondentId == 0) return JsonConvert.SerializeObject(new { Error = "NOT_FOUND" }); if (respondent.TokenExpiry.HasValue && respondent.TokenExpiry.Value < DateTime.UtcNow) return JsonConvert.SerializeObject(new { Error = "LINK_EXPIRED" }); var bridge = await _BridgeDAL.GetBridgeByRespondentId(respondent.FlsRespondentId, crossTenantLogin, ct).ConfigureAwait(false); if (bridge == null) return JsonConvert.SerializeObject(new { Error = "BRIDGE_NOT_FOUND" }); // The tenant to actually operate against is the bridge row's own TenantId, captured // when the link was dispatched — the FLS/respondent lookup itself is cross-tenant, // but the TMS reads/writes below must target the real tenant. var tenantLogin = new LoginDTO { ClientId = bridge.TenantId, UserId = -1 }; int assessmentAttemptId = bridge.AssessmentAttemptId; if (assessmentAttemptId <= 0) { GB5Trace.Step("start-fls-assessment-attempt", new { bridge.AssessmentId, bridge.EnrollmentId }); var startDto = new AssessmentAttemptDTO { AssessmentId = bridge.AssessmentId, EnrollmentId = bridge.EnrollmentId, Responses = new List() }; await _AssessmentAttemptBLL.StartAssessmentAttempt(startDto, tenantLogin).ConfigureAwait(false); assessmentAttemptId = startDto.AssessmentAttemptId; await _BridgeDAL.MarkBridgeOpened(bridge.AssessmentFlsBridgeId, assessmentAttemptId, tenantLogin, ct).ConfigureAwait(false); } var assessment = await _AssessmentDAL.GetAssessment(bridge.AssessmentId, tenantLogin).ConfigureAwait(false); var questions = await _AssessmentDAL.GetSimpleQuestions(bridge.AssessmentId, tenantLogin).ConfigureAwait(false); return JsonConvert.SerializeObject(new { AssessmentAttemptId = assessmentAttemptId, AssessmentId = bridge.AssessmentId, EnrollmentId = bridge.EnrollmentId, Assessment = assessment, Questions = questions }); } public async Task SubmitFlsAttemptAsync(string token, List responses, CancellationToken ct) { if (!Guid.TryParse(token, out var accessToken)) return JsonConvert.SerializeObject(new { Error = "INVALID_TOKEN" }); var crossTenantLogin = new LoginDTO { ClientId = 0, UserId = -1 }; var respondentJson = await _FlsRespondentBLL.GetByTokenAsync(accessToken, crossTenantLogin, ct).ConfigureAwait(false); var respondent = JsonConvert.DeserializeObject(respondentJson); if (respondent == null || respondent.FlsRespondentId == 0) return JsonConvert.SerializeObject(new { Error = "NOT_FOUND" }); var bridge = await _BridgeDAL.GetBridgeByRespondentId(respondent.FlsRespondentId, crossTenantLogin, ct).ConfigureAwait(false); if (bridge == null || bridge.AssessmentAttemptId <= 0) return JsonConvert.SerializeObject(new { Error = "ATTEMPT_NOT_STARTED" }); var tenantLogin = new LoginDTO { ClientId = bridge.TenantId, UserId = -1 }; GB5Trace.Step("submit-fls-assessment-attempt", new { bridge.AssessmentAttemptId }); var submitDto = new AssessmentAttemptDTO { AssessmentAttemptId = bridge.AssessmentAttemptId, AssessmentId = bridge.AssessmentId, EnrollmentId = bridge.EnrollmentId, Responses = responses }; // The existing, unmodified in-app submit path — scoring/evaluation and the // assignment-resolution/skill-trigger hooks fire identically regardless of channel. var result = await _AssessmentAttemptBLL.SubmitAssessmentAttempt(submitDto, tenantLogin).ConfigureAwait(false); await _BridgeDAL.MarkBridgeSubmitted(bridge.AssessmentFlsBridgeId, tenantLogin, ct).ConfigureAwait(false); // Non-fatal: closing out the FLS side (stop nudges) is a courtesy, not something that // should fail the submission if FLS itself has a problem. try { await _FlsSessionBLL.MarkExternalSubmitAsync(respondent.FlsRespondentId, $"tms-attempt:{bridge.AssessmentAttemptId}", crossTenantLogin, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("fls-mark-external-submit-failed", ex); } return result; } } }