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();