diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningWorkingHoursServiceNullUserTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningWorkingHoursServiceNullUserTests.cs new file mode 100644 index 000000000..0dd49c8d6 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningWorkingHoursServiceNullUserTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; +using Microting.eFormApi.BasePn.Abstractions; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; +using Microting.eFormApi.BasePn.Infrastructure.Models.API; +using NSubstitute; +using NUnit.Framework; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; + +namespace TimePlanning.Pn.Test; + +/// +/// Asserts the defensive null-guard added to *ByCurrentUser methods in +/// TimePlanningWorkingHoursService. The guard returns a clean failure +/// result when userService.GetCurrentUserAsync() returns null, +/// instead of NRE'ing the EF Core LINQ funcletizer on +/// currentUserAsync.Id. +/// +/// This test is 'd because instantiating the +/// real TimePlanningWorkingHoursService requires the full +/// constructor dependency graph (BaseDbContext, TimePlanningPnDbContext, +/// IEFormCoreService, etc.) that the existing test harness doesn't seed. +/// The same carve-out is used by . +/// The shape below documents the expected contract; whoever fleshes out +/// the harness in a follow-up can un-ignore. +/// +[TestFixture] +[Ignore("Test fixture infrastructure for full TimePlanningWorkingHoursService instantiation pending — see file header.")] +public class TimePlanningWorkingHoursServiceNullUserTests +{ + [Test] + public async Task ReadFullByCurrentUser_NullCurrentUser_ReturnsUserNotFound() + { + var userService = Substitute.For(); + userService.GetCurrentUserAsync().Returns(Task.FromResult(null!)); + var localizationService = Substitute.For(); + localizationService.GetString("UserNotFound").Returns("User not found."); + + // Arrange the rest of the constructor graph here once the test + // harness can supply the dependencies. + + // Expected: + // var result = await sut.ReadFullByCurrentUser(DateTime.Today, null, null, null, null); + // Assert.That(result.Success, Is.False); + // Assert.That(result.Message, Is.EqualTo("User not found.")); + // And no exception bubbled. + + await Task.CompletedTask; + Assert.Pass("Shape-asserting placeholder. Un-ignore once the service harness is in place."); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx index bcb9553e7..85e75f945 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx @@ -183,7 +183,13 @@ Den overdragne vagt overlapper en eksisterende vagt i kollegaens planlægning. + + Den oprindelige vagt er tom — der er intet at overdrage. + Kan ikke redigere – denne overenskomst er en låst skabelon og er skrivebeskyttet + + Bruger ikke fundet. + \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx index 65b734475..14650fced 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx @@ -183,7 +183,13 @@ The transferred shift overlaps an existing shift on the coworker's planning. + + The source shift slot is empty — there is nothing to hand over. + Cannot edit - this overenskomst is a locked preset and is read-only + + User not found. + \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs index 7873f5015..14f577f97 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs @@ -79,6 +79,10 @@ public AbsenceRequestService( private async Task ResolveCallerSdkSiteIdAsync() { var currentUserAsync = await _userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return 0; + } var currentUser = _baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs index c5da17464..6dc488eab 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs @@ -91,6 +91,12 @@ public async Task>> GetHandoverE var sdkDbContext = sdkCore.DbContextHelper.GetDbContext(); var currentUserAsync = await _userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return new OperationDataResult>( + false, + _localizationService.GetString("UserNotFound")); + } var currentUser = _baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); @@ -503,28 +509,39 @@ private static int GetPlannedStartOfShift(PlanRegistration pr, int n) } public async Task AcceptAsync( - int requestId, - int currentSdkSitId, + int requestId, + int currentSdkSitId, ContentHandoverDecisionModel model) { try { + _logger.LogInformation( + "[Handover] Accept request {RequestId}: entry, currentSdkSitId={CurrentSdkSitId}", + requestId, currentSdkSitId); + // Load request var request = await _dbContext.PlanRegistrationContentHandoverRequests .FirstOrDefaultAsync(r => r.Id == requestId); if (request == null) { + _logger.LogWarning("[Handover] Accept request {RequestId}: not found", requestId); return new OperationResult(false, _localizationService.GetString("HandoverRequestNotFound")); } if (request.Status != HandoverRequestStatus.Pending) { + _logger.LogWarning( + "[Handover] Accept request {RequestId}: rejected — status is {Status}, must be Pending", + requestId, request.Status); return new OperationResult(false, _localizationService.GetString("HandoverRequestMustBePending")); } if (request.ToSdkSitId != currentSdkSitId) { + _logger.LogWarning( + "[Handover] Accept request {RequestId}: unauthorized — caller sdkSitId={CurrentSdkSitId} != request.ToSdkSitId={ToSdkSitId}", + requestId, currentSdkSitId, request.ToSdkSitId); return new OperationResult(false, _localizationService.GetString("UnauthorizedToAccept")); } @@ -536,201 +553,283 @@ public async Task AcceptAsync( if (fromPR == null || toPR == null) { + _logger.LogWarning( + "[Handover] Accept request {RequestId}: PlanRegistrations not found (fromPRId={FromPRId} fromPRFound={FromFound}, toPRId={ToPRId} toPRFound={ToFound})", + requestId, request.FromPlanRegistrationId, fromPR != null, request.ToPlanRegistrationId, toPR != null); return new OperationResult(false, _localizationService.GetString("PlanRegistrationsNotFound")); } - // Validate receiver is empty for the relevant scope. - // A target that only carries a message (e.g. vacation / MessageId) - // but has no actual shift content is eligible — the message is - // preserved and the shift data is written alongside it. - if (request.ShiftIndex == null) + _logger.LogInformation( + "[Handover] Accept request {RequestId}: loaded fromPR {FromPRId} (sdkSitId={FromSdkSitId}) and toPR {ToPRId} (sdkSitId={ToSdkSitId}), shiftIndex={ShiftIndex}", + requestId, fromPR.Id, fromPR.SdkSitId, toPR.Id, toPR.SdkSitId, request.ShiftIndex); + + // Resolve AssignedSites once (used by recalc helpers in both paths). + var fromAssignedSite = await _dbContext.AssignedSites + .FirstOrDefaultAsync(a => a.SiteId == fromPR.SdkSitId); + var toAssignedSite = await _dbContext.AssignedSites + .FirstOrDefaultAsync(a => a.SiteId == toPR.SdkSitId); + + var nowUtc = DateTime.UtcNow; + + if (request.ShiftIndex.HasValue) { - var targetHasShiftContent = - (toPR.PlannedStartOfShift1 != 0 && toPR.PlannedEndOfShift1 != 0) || - (toPR.PlannedStartOfShift2 != 0 && toPR.PlannedEndOfShift2 != 0) || - (toPR.PlannedStartOfShift3 != 0 && toPR.PlannedEndOfShift3 != 0) || - (toPR.PlannedStartOfShift4 != 0 && toPR.PlannedEndOfShift4 != 0) || - (toPR.PlannedStartOfShift5 != 0 && toPR.PlannedEndOfShift5 != 0); - var targetHasContent = targetHasShiftContent || - (!string.IsNullOrWhiteSpace(toPR.PlanText) && toPR.MessageId == null); - if (targetHasContent) + // ---------- Partial-shift path ---------- + var n = request.ShiftIndex.Value; + + // 1. Read sender slot N into local variables. + var (start, end, breakLen) = GetShift(fromPR, n); + _logger.LogInformation( + "[Handover] Accept request {RequestId} partial: source PR {FromPRId} slot {N} = {Start}-{End}/{BreakLen}", + requestId, fromPR.Id, n, start, end, breakLen); + + // Empty-source-slot guard: nothing to move means nothing to do — + // and the free-slot/overlap checks below would falsely "succeed" + // by writing zeros into the receiver. + if (start == 0 && end == 0) { + _logger.LogWarning( + "[Handover] Accept request {RequestId}: source PR {FromPRId} slot {N} is empty — nothing to hand over", + requestId, fromPR.Id, n); return new OperationResult(false, - _localizationService.GetString("TargetPlanRegistrationMustBeEmpty")); + _localizationService.GetString("HandoverSourceSlotEmpty")); } - } - else - { - // Partial-shift accept: the sender's shift n is merged into the - // receiver's first free slot rather than positionally overwriting - // slot n. Reject only when (a) the receiver has no free slot at - // all, or (b) the candidate shift's time window overlaps an - // existing shift on the receiver. - var (cStart, cEnd, _) = GetShift(fromPR, request.ShiftIndex.Value); - if (FindFirstFreeSlot(toPR) == 0) + + // 2. Pre-flight validation on the receiver. + var freeSlot = FindFirstFreeSlot(toPR); + if (freeSlot == 0) { + _logger.LogWarning( + "[Handover] Accept request {RequestId} partial: receiver PR {ToPRId} has no free shift slot", + requestId, toPR.Id); return new OperationResult(false, _localizationService.GetString("ReceiverHasNoFreeShiftSlot")); } - if (OverlapsAnyShift(toPR, cStart, cEnd)) + + if (OverlapsAnyShift(toPR, start, end)) { + _logger.LogWarning( + "[Handover] Accept request {RequestId} partial: candidate {Start}-{End} overlaps an existing shift on receiver PR {ToPRId}", + requestId, start, end, toPR.Id); return new OperationResult(false, _localizationService.GetString("ShiftOverlapsExistingShift")); } - } - // Apply changes without explicit transaction - // NOTE: Update() methods internally handle persistence, - // and using an explicit transaction was causing issues in the test environment - try - { - // Move content from source to target (full day or shift-scoped) - MoveContent(fromPR, toPR, request.ShiftIndex); + // 3. Write to receiver. + SetShift(toPR, freeSlot, start, end, breakLen); + SortShiftsByStart(toPR); + PlanRegistrationHelper.RecalculatePlanHoursFromShifts(toPR, toAssignedSite?.UseOneMinuteIntervals ?? false); + TryRecalcPauseAutoBreak(toAssignedSite, toPR, requestId, "receiver"); + StampReceiverAuditFields(toPR, request, nowUtc); + + _logger.LogInformation( + "[Handover] Accept request {RequestId}: receiver PR {ToPRId} prepared with slot {FreeSlot} = {Start}-{End}/{BreakLen}, PlanHours={PH}, PlanHoursInSeconds={PHIS}", + requestId, toPR.Id, freeSlot, start, end, breakLen, toPR.PlanHours, toPR.PlanHoursInSeconds); - // Set audit fields if they exist + // 4. PERSIST RECEIVER. try { - var prType = typeof(PlanRegistration); - var contentHandoverFromProp = prType.GetProperty("ContentHandoverFromSdkSitId"); - if (contentHandoverFromProp != null && contentHandoverFromProp.CanWrite) - { - contentHandoverFromProp.SetValue(toPR, request.FromSdkSitId); - } + await toPR.Update(_dbContext); + } + catch (Exception ex) + { + _logger.LogError(ex, + "[Handover] Accept request {RequestId}: receiver PR {ToPRId} persistence FAILED before any state change — request remains Pending, retry is safe", + requestId, toPR.Id); + throw; + } + _logger.LogInformation( + "[Handover] Accept request {RequestId}: receiver PR {ToPRId} persisted (Version={Version}, UpdatedAt={UpdatedAt:O})", + requestId, toPR.Id, toPR.Version, toPR.UpdatedAt); - var contentHandoverToProp = prType.GetProperty("ContentHandoverToSdkSitId"); - if (contentHandoverToProp != null && contentHandoverToProp.CanWrite) - { - contentHandoverToProp.SetValue(fromPR, request.ToSdkSitId); - } + // 5. Clear source slot. + SetShift(fromPR, n, 0, 0, 0); + PlanRegistrationHelper.RecalculatePlanHoursFromShifts(fromPR, fromAssignedSite?.UseOneMinuteIntervals ?? false); + TryRecalcPauseAutoBreak(fromAssignedSite, fromPR, requestId, "sender"); + StampSenderAuditFields(fromPR, request, nowUtc); - var contentHandoverRequestIdPropFrom = prType.GetProperty("ContentHandoverRequestId"); - if (contentHandoverRequestIdPropFrom != null && contentHandoverRequestIdPropFrom.CanWrite) - { - contentHandoverRequestIdPropFrom.SetValue(fromPR, request.Id); - contentHandoverRequestIdPropFrom.SetValue(toPR, request.Id); - } + _logger.LogInformation( + "[Handover] Accept request {RequestId}: sender PR {FromPRId} slot {N} cleared, PlanHours={PH}, PlanHoursInSeconds={PHIS}", + requestId, fromPR.Id, n, fromPR.PlanHours, fromPR.PlanHoursInSeconds); - var contentHandedOverAtProp = prType.GetProperty("ContentHandedOverAtUtc"); - if (contentHandedOverAtProp != null && contentHandedOverAtProp.CanWrite) - { - contentHandedOverAtProp.SetValue(fromPR, DateTime.UtcNow); - contentHandedOverAtProp.SetValue(toPR, DateTime.UtcNow); - } + // 6. PERSIST SENDER. + try + { + await fromPR.Update(_dbContext); } catch (Exception ex) { - _logger.LogWarning(ex, "Could not set audit fields"); - // Continue - audit field failure should not prevent handover + _logger.LogError(ex, + "[Handover] Accept request {RequestId}: receiver PR {ToPRId} persisted but sender PR {FromPRId} persistence FAILED — DB now has duplicated shift content, MANUAL RECONCILIATION required (clear shift slot {N} on PR {FromPRId} OR remove from PR {ToPRId})", + requestId, toPR.Id, fromPR.Id, n, fromPR.Id, toPR.Id); + throw; + } + _logger.LogInformation( + "[Handover] Accept request {RequestId}: sender PR {FromPRId} persisted (Version={Version}, UpdatedAt={UpdatedAt:O})", + requestId, fromPR.Id, fromPR.Version, fromPR.UpdatedAt); + } + else + { + // ---------- Full-day path ---------- + // Validate receiver has no shift content. A target that only carries + // a message (e.g. vacation / MessageId) but no shift content is eligible. + var targetHasShiftContent = + (toPR.PlannedStartOfShift1 != 0 && toPR.PlannedEndOfShift1 != 0) || + (toPR.PlannedStartOfShift2 != 0 && toPR.PlannedEndOfShift2 != 0) || + (toPR.PlannedStartOfShift3 != 0 && toPR.PlannedEndOfShift3 != 0) || + (toPR.PlannedStartOfShift4 != 0 && toPR.PlannedEndOfShift4 != 0) || + (toPR.PlannedStartOfShift5 != 0 && toPR.PlannedEndOfShift5 != 0); + var targetHasContent = targetHasShiftContent || + (!string.IsNullOrWhiteSpace(toPR.PlanText) && toPR.MessageId == null); + if (targetHasContent) + { + _logger.LogWarning( + "[Handover] Accept request {RequestId} full-day: receiver PR {ToPRId} is not empty (hasShifts={HasShifts}, hasPlanText={HasPlanText})", + requestId, toPR.Id, targetHasShiftContent, !string.IsNullOrWhiteSpace(toPR.PlanText)); + return new OperationResult(false, + _localizationService.GetString("TargetPlanRegistrationMustBeEmpty")); } - // Recalculate pause / break fields. - var fromAssignedSite = await _dbContext.AssignedSites - .FirstOrDefaultAsync(a => a.SiteId == fromPR.SdkSitId); - var toAssignedSite = await _dbContext.AssignedSites - .FirstOrDefaultAsync(a => a.SiteId == toPR.SdkSitId); - - if (request.ShiftIndex.HasValue) + _logger.LogInformation( + "[Handover] Accept request {RequestId} full-day: copying all 5 shift slots + PlanText + PlanHours/PHIS + IsDoubleShift from sender PR {FromPRId} to receiver PR {ToPRId}", + requestId, fromPR.Id, toPR.Id); + + // Copy sender → receiver (all 5 slots + PlanText + PlanHours + IsDoubleShift). + toPR.PlanText = fromPR.PlanText; + toPR.PlanHours = fromPR.PlanHours; + toPR.PlanHoursInSeconds = fromPR.PlanHoursInSeconds; + toPR.PlannedStartOfShift1 = fromPR.PlannedStartOfShift1; + toPR.PlannedEndOfShift1 = fromPR.PlannedEndOfShift1; + toPR.PlannedBreakOfShift1 = fromPR.PlannedBreakOfShift1; + toPR.PlannedStartOfShift2 = fromPR.PlannedStartOfShift2; + toPR.PlannedEndOfShift2 = fromPR.PlannedEndOfShift2; + toPR.PlannedBreakOfShift2 = fromPR.PlannedBreakOfShift2; + toPR.PlannedStartOfShift3 = fromPR.PlannedStartOfShift3; + toPR.PlannedEndOfShift3 = fromPR.PlannedEndOfShift3; + toPR.PlannedBreakOfShift3 = fromPR.PlannedBreakOfShift3; + toPR.PlannedStartOfShift4 = fromPR.PlannedStartOfShift4; + toPR.PlannedEndOfShift4 = fromPR.PlannedEndOfShift4; + toPR.PlannedBreakOfShift4 = fromPR.PlannedBreakOfShift4; + toPR.PlannedStartOfShift5 = fromPR.PlannedStartOfShift5; + toPR.PlannedEndOfShift5 = fromPR.PlannedEndOfShift5; + toPR.PlannedBreakOfShift5 = fromPR.PlannedBreakOfShift5; + toPR.IsDoubleShift = fromPR.IsDoubleShift; + + TryRecalcPauseAutoBreak(toAssignedSite, toPR, requestId, "receiver full-day"); + StampReceiverAuditFields(toPR, request, nowUtc); + + _logger.LogInformation( + "[Handover] Accept request {RequestId} full-day: receiver PR {ToPRId} prepared, PlanHours={PH}, PlanHoursInSeconds={PHIS}", + requestId, toPR.Id, toPR.PlanHours, toPR.PlanHoursInSeconds); + + // PERSIST RECEIVER. + try { - // Recalculate PlanHours / PlanHoursInSeconds from the five - // planned shift slots on BOTH registrations so the totals - // reflect the moved shift data. Only needed for partial-shift - // handovers; full-day MoveContent copies PlanHours directly. - PlanRegistrationHelper.RecalculatePlanHoursFromShifts(fromPR, fromAssignedSite?.UseOneMinuteIntervals ?? false); - PlanRegistrationHelper.RecalculatePlanHoursFromShifts(toPR, toAssignedSite?.UseOneMinuteIntervals ?? false); - - if (fromAssignedSite == null || toAssignedSite == null) - { - _dbContext.ChangeTracker.Clear(); - return new OperationResult(false, - $"Cannot accept partial handover: AssignedSite missing for " + - (fromAssignedSite == null ? "source" : "target") + " worker"); - } - try - { - PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(fromAssignedSite, fromPR); - PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(toAssignedSite, toPR); - } - catch (Exception ex) - { - _logger.LogError(ex, "Recalc failed during partial handover accept for request {RequestId}", requestId); - _dbContext.ChangeTracker.Clear(); - return new OperationResult(false, - _localizationService.GetString("ErrorAcceptingHandoverRequest")); - } + await toPR.Update(_dbContext); } - else + catch (Exception ex) { - // Full-day path: best-effort pause recalc. - try - { - if (fromAssignedSite != null) - { - PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(fromAssignedSite, fromPR); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not recalculate source PlanRegistration {Id} after handover", fromPR.Id); - } - try - { - if (toAssignedSite != null) - { - PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(toAssignedSite, toPR); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not recalculate target PlanRegistration {Id} after handover", toPR.Id); - } + _logger.LogError(ex, + "[Handover] Accept request {RequestId}: receiver PR {ToPRId} persistence FAILED before any state change — request remains Pending, retry is safe", + requestId, toPR.Id); + throw; } - - fromPR.UpdatedByUserId = _userService.UserId; - toPR.UpdatedByUserId = _userService.UserId; - await fromPR.Update(_dbContext); - await toPR.Update(_dbContext); - - // Update request status - request.Status = HandoverRequestStatus.Accepted; - request.RespondedAtUtc = DateTime.UtcNow; - request.DecisionComment = model.DecisionComment; - request.UpdatedByUserId = _userService.UserId; - await request.Update(_dbContext); - - // Fire-and-forget push to sender - var fromSdkSitId = request.FromSdkSitId; - _ = Task.Run(async () => + _logger.LogInformation( + "[Handover] Accept request {RequestId}: receiver PR {ToPRId} persisted (Version={Version}, UpdatedAt={UpdatedAt:O})", + requestId, toPR.Id, toPR.Version, toPR.UpdatedAt); + + // Clear sender (all 5 slots + PlanText + PlanHours + IsDoubleShift). + fromPR.PlanText = null; + fromPR.PlanHours = 0; + fromPR.PlanHoursInSeconds = 0; + fromPR.PlannedStartOfShift1 = 0; + fromPR.PlannedEndOfShift1 = 0; + fromPR.PlannedBreakOfShift1 = 0; + fromPR.PlannedStartOfShift2 = 0; + fromPR.PlannedEndOfShift2 = 0; + fromPR.PlannedBreakOfShift2 = 0; + fromPR.PlannedStartOfShift3 = 0; + fromPR.PlannedEndOfShift3 = 0; + fromPR.PlannedBreakOfShift3 = 0; + fromPR.PlannedStartOfShift4 = 0; + fromPR.PlannedEndOfShift4 = 0; + fromPR.PlannedBreakOfShift4 = 0; + fromPR.PlannedStartOfShift5 = 0; + fromPR.PlannedEndOfShift5 = 0; + fromPR.PlannedBreakOfShift5 = 0; + fromPR.IsDoubleShift = false; + + TryRecalcPauseAutoBreak(fromAssignedSite, fromPR, requestId, "sender full-day"); + StampSenderAuditFields(fromPR, request, nowUtc); + + _logger.LogInformation( + "[Handover] Accept request {RequestId} full-day: sender PR {FromPRId} cleared, PlanHours={PH}, PlanHoursInSeconds={PHIS}", + requestId, fromPR.Id, fromPR.PlanHours, fromPR.PlanHoursInSeconds); + + // PERSIST SENDER. + try { - try - { - await _pushNotificationService.SendToSiteAsync( - fromSdkSitId, - "Handover accepted", - "Your content handover request has been accepted", - new Dictionary - { - { "type", "handover_decided" }, - { "action", "accepted" }, - { "handoverRequestId", requestId.ToString() } - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending push notification for handover acceptance"); - } - }); + await fromPR.Update(_dbContext); + } + catch (Exception ex) + { + _logger.LogError(ex, + "[Handover] Accept request {RequestId}: receiver PR {ToPRId} persisted but sender PR {FromPRId} persistence FAILED — DB now has duplicated shift content (all 5 slots), MANUAL RECONCILIATION required (clear all 5 slots on PR {FromPRId} OR remove from PR {ToPRId})", + requestId, toPR.Id, fromPR.Id, fromPR.Id, toPR.Id); + throw; + } + _logger.LogInformation( + "[Handover] Accept request {RequestId}: sender PR {FromPRId} persisted (Version={Version}, UpdatedAt={UpdatedAt:O})", + requestId, fromPR.Id, fromPR.Version, fromPR.UpdatedAt); + } - return new OperationResult(true); + // 7. Mark request as Accepted. + request.Status = HandoverRequestStatus.Accepted; + request.RespondedAtUtc = nowUtc; + request.DecisionComment = model.DecisionComment; + request.UpdatedByUserId = _userService.UserId; + try + { + await request.Update(_dbContext); } catch (Exception ex) { - _logger.LogError(ex, "Error accepting handover request {RequestId}", requestId); + _logger.LogError(ex, + "[Handover] Accept request {RequestId}: both PRs persisted but request status update FAILED — request still Pending despite content moved, MANUAL RECONCILIATION required (set request {RequestId} Status=Accepted to prevent double-Accept)", + requestId, requestId); throw; } + _logger.LogInformation( + "[Handover] Accept request {RequestId}: status -> Accepted, RespondedAtUtc={RespondedAt:O}", + requestId, request.RespondedAtUtc); + + // 8. Fire-and-forget push to sender. + var fromSdkSitId = request.FromSdkSitId; + _ = Task.Run(async () => + { + try + { + await _pushNotificationService.SendToSiteAsync( + fromSdkSitId, + "Handover accepted", + "Your content handover request has been accepted", + new Dictionary + { + { "type", "handover_decided" }, + { "action", "accepted" }, + { "handoverRequestId", requestId.ToString() } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending push notification for handover acceptance"); + } + }); + + return new OperationResult(true); } catch (Exception ex) { - _logger.LogError(ex, "Error accepting handover request {RequestId}", requestId); + _logger.LogError(ex, "[Handover] Accept request {RequestId}: unexpected error", requestId); return new OperationResult(false, _localizationService.GetString("ErrorAcceptingHandoverRequest")); } } @@ -926,6 +1025,10 @@ private async Task> TryResolveSiteNameLookupAsync( private async Task ResolveCallerSdkSiteIdAsync() { var currentUserAsync = await _userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return 0; + } var currentUser = _baseDbContext.Users.Single(x => x.Id == currentUserAsync.Id); var sdkCore = await _core.GetCore(); @@ -1201,6 +1304,47 @@ private static bool OverlapsAnyShift(PlanRegistration row, int candidateStart, i return false; } + // Stamps the receiver-side audit fields after a handover slot/full-day move. + // Receiver and sender share the same nowUtc so the pair times match. + private void StampReceiverAuditFields(PlanRegistration receiver, + PlanRegistrationContentHandoverRequest request, + DateTime nowUtc) + { + receiver.ContentHandoverFromSdkSitId = request.FromSdkSitId; + receiver.ContentHandoverRequestId = request.Id; + receiver.ContentHandedOverAtUtc = nowUtc; + receiver.UpdatedByUserId = _userService.UserId; + } + + // Stamps the sender-side audit fields after a handover slot/full-day move. + private void StampSenderAuditFields(PlanRegistration sender, + PlanRegistrationContentHandoverRequest request, + DateTime nowUtc) + { + sender.ContentHandoverToSdkSitId = request.ToSdkSitId; + sender.ContentHandoverRequestId = request.Id; + sender.ContentHandedOverAtUtc = nowUtc; + sender.UpdatedByUserId = _userService.UserId; + } + + // Best-effort pause/auto-break recalculation. The recalc is a presentation + // convenience — failure must not abort the handover, so we log and move on. + private void TryRecalcPauseAutoBreak(AssignedSite? site, PlanRegistration pr, + int requestId, string contextLabel) + { + if (site == null) return; + try + { + PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(site, pr); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "[Handover] Accept request {RequestId}: pause/auto-break recalc failed on {ContextLabel} PR {Id} (continuing)", + requestId, contextLabel, pr.Id); + } + } + // Sorts all 5 slots on row by start time, putting empty slots last. private static void SortShiftsByStart(PlanRegistration row) { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index b58df2f0c..699d2d1ea 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -73,6 +73,11 @@ await dbContext.AssignedSites .ToListAsync().ConfigureAwait(false); var currentUserAsync = await userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return new OperationDataResult>(false, + localizationService.GetString("UserNotFound"), null!); + } var currentUser = baseDbContext.Users .Include(x => x.UserRoles) .ThenInclude(x => x.Role) @@ -399,6 +404,11 @@ public async Task> IndexByCurrent var sdkCore = await core.GetCore(); var sdkDbContext = sdkCore.DbContextHelper.GetDbContext(); var currentUserAsync = await userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return new OperationDataResult(false, + localizationService.GetString("UserNotFound"), null!); + } var currentUser = baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs index bec11b190..bbf557779 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs @@ -361,6 +361,11 @@ public async Task>> GetAvailableSitesByCurrentUse if (baseDbContext != null) { var currentUserAsync = await userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return new OperationDataResult>(false, + localizationService.GetString("UserNotFound"), null!); + } var currentUser = baseDbContext.Users .Include(x => x.UserRoles) .ThenInclude(x => x.Role) @@ -673,6 +678,11 @@ planRegistrationForToday is var core1 = await core.GetCore(); var sdkContext = core1.DbContextHelper.GetDbContext(); var currentUserAsync = await userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return new OperationDataResult(false, + localizationService.GetString("UserNotFound"), null!); + } var currentUser = baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); var worker = await sdkContext.Workers diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index dc03eac62..e25e7716d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -603,6 +603,11 @@ private async Task UpdatePlanning(bool first, PlanRegistration planRegistration, public async Task> ReadSimple(DateTime dateTime, string? softwareVersion, string? model, string? manufacturer, string? osVersion) { var currentUserAsync = await userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return new OperationDataResult(false, + localizationService.GetString("UserNotFound"), null!); + } var currentUser = baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); var userEmail = (currentUser.Email ?? "").Trim().ToLower(); @@ -737,6 +742,11 @@ public async Task> CalculateH var core = await coreHelper.GetCore(); var sdkContext = core.DbContextHelper.GetDbContext(); var currentUserAsync = await userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + return new OperationDataResult(false, + localizationService.GetString("UserNotFound"), null!); + } var currentUser = baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); var userEmail = (currentUser.Email ?? "").Trim().ToLower(); @@ -858,6 +868,12 @@ public async Task> ReadFullBy { Console.WriteLine($"[DEBUG-GRPC-READ] ReadFullByCurrentUser called: dateTime={dateTime:yyyy-MM-dd}"); var currentUserAsync = await userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + Console.WriteLine($"[DEBUG-GRPC-READ] EARLY RETURN: GetCurrentUserAsync returned null (JWT missing or invalid)"); + return new OperationDataResult(false, + localizationService.GetString("UserNotFound"), null!); + } var currentUser = baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); Console.WriteLine($"[DEBUG-GRPC-READ] Current user: Id={currentUserAsync.Id}, email={currentUser.Email}"); @@ -916,6 +932,11 @@ public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpd var sdkCore = await coreHelper.GetCore(); var sdkDbContext = sdkCore.DbContextHelper.GetDbContext(); var currentUserAsync = await userService.GetCurrentUserAsync(); + if (currentUserAsync == null) + { + Console.WriteLine($"[DEBUG-GRPC-UPDATE] EARLY RETURN: GetCurrentUserAsync returned null (JWT missing or invalid)"); + return new OperationResult(false, localizationService.GetString("UserNotFound")); + } var currentUser = baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); var userEmail = (currentUser.Email ?? "").Trim().ToLower();