From 8c13f66c99574fa77c066a4ab0a6858dfe77fb0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 6 May 2026 14:24:26 +0200 Subject: [PATCH 01/22] fix(opgaver-grpc): update PlanningCaseSite + PlanningCase on completion OpgaverGrpcService.CompleteOpgave previously only updated the SDK Case row (Status=100, DoneAt, DoneAtUserModifiable) and soft-deleted the Compliance row. The companion PlanningCaseSite and PlanningCase rows (in the items_planning plugin DB) were never touched, leaving them at Status=66/77 with MicrotingSdkCaseDoneAt=NULL. The admin "filled cases" view queries PlanningCases WHERE Status=100 AND MicrotingSdkCaseDoneAt >= fromDate, so completed opgaver from device never appeared in that view. Now mirrors the post-update sequence used by BackendConfigurationCompliancesService.Update: locate the PlanningCaseSite by CreatedAt.Date==compliance.StartDate.Date and PlanningId==compliance.PlanningId, set Status=100 + MicrotingSdkCaseDoneAt=foundCase.DoneAt + DoneByUserId/Name, persist via .Update(); then locate the parent PlanningCase, set Status=100 + MicrotingSdkCaseDoneAt=foundCase.DoneAt + WorkflowState=Processed, persist. Also injects ItemsPlanningPnDbContext into the primary constructor and adds the Microting.ItemsPlanningBase.Infrastructure.Data using. No hard deletes anywhere in this change. All entity removals (the existing Compliance soft-delete) continue to use entity.Delete(dbContext). Closes the gap discovered via direct DB query: device-completed cases have correct Cases.Status=100 + DoneAtUserModifiable but stale PlanningCaseSites/PlanningCases. Co-Authored-By: Claude Sonnet 4.6 --- .../GrpcServices/OpgaverGrpcService.cs | 64 ++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 6c5d32603..b61a09372 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -21,6 +21,7 @@ using Microting.eForm.Infrastructure.Models; using Microting.eFormApi.BasePn.Abstractions; using Microting.EformBackendConfigurationBase.Infrastructure.Data; +using Microting.ItemsPlanningBase.Infrastructure.Data; using SdkUploadedData = Microting.eForm.Infrastructure.Data.Entities.UploadedData; using SdkDataItem = Microting.eForm.Infrastructure.Models.DataItem; using SdkElement = Microting.eForm.Infrastructure.Models.Element; @@ -64,17 +65,20 @@ namespace BackendConfiguration.Pn.Services.GrpcServices; /// for poll-window semantics. /// /// Known divergences from the canonical -/// BackendConfigurationCompliancesService.Update JSON path that -/// CompleteOpgave does NOT perform (parity with -/// CompliancesGrpcService.UpdateComplianceCase, which has the same -/// gaps — this PR introduces no new divergence): +/// BackendConfigurationCompliancesService.Update JSON path. +/// CompleteOpgave NOW performs (added in this PR): /// /// PlanningCaseSite row update (Status=100, /// MicrotingSdkCaseId, MicrotingSdkCaseDoneAt, DoneByUserId, -/// DoneByUserName) — see +/// DoneByUserName) — mirrors /// BackendConfigurationCompliancesService.cs:307-318. /// PlanningCase row update (Status=100, -/// WorkflowState=Processed) — lines 320-335. +/// WorkflowState=Processed, MicrotingSdkCaseDoneAt, DoneByUserId, +/// DoneByUserName) — mirrors lines 320-335. +/// +/// Still omitted (deferred; closing the full gap requires factoring a shared +/// completion helper — out of scope for this PR): +/// /// Property.ComplianceStatus / /// ComplianceStatusThirty recomputation — lines 344-371. Without /// this, the property compliance "dot" UI elsewhere in the system will be @@ -85,9 +89,6 @@ namespace BackendConfiguration.Pn.Services.GrpcServices; /// core.CaseDelete of the underlying microting /// case — lines 373-389. The device-side case won't be deleted. /// -/// Known limitation; closing the gap likely requires factoring a shared -/// completion helper called by both Update and the gRPC paths — out of -/// scope for this PR. /// public class OpgaverGrpcService( IBackendConfigurationCalendarService calendarService, @@ -96,6 +97,7 @@ public class OpgaverGrpcService( IGrpcSiteResolver siteResolver, IEFormCoreService coreHelper, BackendConfigurationPnDbContext dbContext, + ItemsPlanningPnDbContext itemsPlanningPnDbContext, ILogger logger) : Opgaver.OpgaverBase { @@ -968,6 +970,50 @@ public override async Task CompleteOpgave( foundCase.Status = 100; foundCase.WorkflowState = Constants.WorkflowStates.Created; await foundCase.Update(sdkDbContext).ConfigureAwait(false); + + // Mirror the post-update sequence from + // BackendConfigurationCompliancesService.Update (lines 307-335): + // set Status=100 on PlanningCaseSite + parent PlanningCase so the + // admin "filled cases" view (queries PlanningCases WHERE Status=100 + // AND MicrotingSdkCaseDoneAt >= fromDate) picks up device completions. + var siteName = (await sdkDbContext.Sites + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .FirstOrDefaultAsync(x => x.Id == sdkSiteId) + .ConfigureAwait(false))?.Name ?? string.Empty; + + var planningCaseSite = await itemsPlanningPnDbContext.PlanningCaseSites + .FirstOrDefaultAsync(x => + x.CreatedAt.Date == compliance.StartDate.Date && + x.PlanningId == compliance.PlanningId) + .ConfigureAwait(false); + + if (planningCaseSite != null) + { + planningCaseSite.Status = 100; + planningCaseSite.MicrotingSdkCaseId = foundCase.Id; + planningCaseSite.MicrotingSdkCaseDoneAt = foundCase.DoneAt; + planningCaseSite.DoneByUserId = (int)sdkSiteId; + planningCaseSite.DoneByUserName = siteName; + await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false); + + var planningCase = await itemsPlanningPnDbContext.PlanningCases + .SingleAsync(x => x.Id == planningCaseSite.PlanningCaseId) + .ConfigureAwait(false); + + if (planningCase.Status != 100) + { + planningCase.Status = 100; + planningCase.MicrotingSdkCaseDoneAt = foundCase.DoneAt; + planningCase.MicrotingSdkCaseId = foundCase.Id; + planningCase.DoneByUserId = (int)sdkSiteId; + planningCase.DoneByUserName = siteName; + planningCase.WorkflowState = Constants.WorkflowStates.Processed; + await planningCase.Update(itemsPlanningPnDbContext).ConfigureAwait(false); + } + + planningCaseSite.PlanningCaseId = planningCase.Id; + await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false); + } } // Re-read the calendar tasks for the day in question so we can return From 5edb564c6e89f00c9f38947564c7a2709808c03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 6 May 2026 15:02:49 +0200 Subject: [PATCH 02/22] fixup(opgaver-grpc): lookup PlanningCaseSite by MicrotingSdkCaseId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix updated PlanningCaseSite/PlanningCase fields, but the lookup predicate (CreatedAt.Date == compliance.StartDate.Date) silently missed for recurring/weekly/monthly tasks where PlanningCaseSite.CreatedAt predates the compliance.StartDate by days/weeks. When no row matched, the updates were silently skipped, PlanningCase.MicrotingSdkCaseDoneAt stayed NULL, and the admin reportsv2 query (PlanningCases WHERE Status=100 AND MicrotingSdkCaseDoneAt BETWEEN @from AND @to) returned no results. Switched to lookup by MicrotingSdkCaseId == foundCase.Id — the immutable 1:1 link populated when the SDK case is created. Plus a WorkflowState filter for safety (soft-deleted rows excluded). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/GrpcServices/OpgaverGrpcService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index b61a09372..ff556dba3 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -982,9 +982,9 @@ public override async Task CompleteOpgave( .ConfigureAwait(false))?.Name ?? string.Empty; var planningCaseSite = await itemsPlanningPnDbContext.PlanningCaseSites + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) .FirstOrDefaultAsync(x => - x.CreatedAt.Date == compliance.StartDate.Date && - x.PlanningId == compliance.PlanningId) + x.MicrotingSdkCaseId == foundCase.Id) .ConfigureAwait(false); if (planningCaseSite != null) From 3f20b89e64fecb43deee099110824a6095a7891a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 6 May 2026 17:02:26 +0200 Subject: [PATCH 03/22] fix(opgaver-grpc): treat NULL WorkflowState as not-removed in queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .Where(x => x.WorkflowState != WorkflowStates.Removed) filter excluded rows with NULL WorkflowState because in SQL `NULL != 'removed'` evaluates to NULL (false). 85 of 2681 AreaRulePlannings have NULL WorkflowState (legacy rows pre-dating the workflow-state convention). Tapping any opgave whose ARP had NULL WorkflowState resulted in arp == null → NotFound error → no DB writes → "the opgave reappears" symptom. Audited the entire file and applied the canonical pattern used across the codebase: WorkflowState != 'removed' OR WorkflowState IS NULL. This now matches the SQL the rest of the project produces (e.g. via the items-planning generated queries). No hard deletes anywhere. Read filters only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/GrpcServices/OpgaverGrpcService.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index ff556dba3..28e64be34 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -901,7 +901,7 @@ public override async Task CompleteOpgave( // Look up the AreaRulePlanning to learn its property + ItemPlanningId. // ItemPlanningId is the join key into Compliances.PlanningId. var arp = await dbContext.AreaRulePlannings - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .FirstOrDefaultAsync(x => x.Id == opgaveId) .ConfigureAwait(false); @@ -924,7 +924,7 @@ public override async Task CompleteOpgave( // anything else as a future occurrence with no Case to update — so the // absence of a compliance row is a hard error here. var compliance = await dbContext.Compliances - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .Where(x => x.PlanningId == arp.ItemPlanningId) .OrderBy(x => x.Deadline) .FirstOrDefaultAsync() @@ -977,12 +977,12 @@ public override async Task CompleteOpgave( // admin "filled cases" view (queries PlanningCases WHERE Status=100 // AND MicrotingSdkCaseDoneAt >= fromDate) picks up device completions. var siteName = (await sdkDbContext.Sites - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .FirstOrDefaultAsync(x => x.Id == sdkSiteId) .ConfigureAwait(false))?.Name ?? string.Empty; var planningCaseSite = await itemsPlanningPnDbContext.PlanningCaseSites - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .FirstOrDefaultAsync(x => x.MicrotingSdkCaseId == foundCase.Id) .ConfigureAwait(false); @@ -1151,7 +1151,7 @@ public override async Task SetComment( var opgaveId = ParseOpgaveId(request.OpgaveId); var arp = await dbContext.AreaRulePlannings - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .FirstOrDefaultAsync(x => x.Id == opgaveId) .ConfigureAwait(false); @@ -1374,7 +1374,7 @@ public override async Task UploadPhoto( // 3. Auth + property access. Mirrors CompleteOpgave / SetComment. var arp = await dbContext.AreaRulePlannings - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .FirstOrDefaultAsync(x => x.Id == opgaveId) .ConfigureAwait(false); @@ -1564,7 +1564,7 @@ public override async Task RemovePhoto( } var arp = await dbContext.AreaRulePlannings - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .FirstOrDefaultAsync(x => x.Id == opgaveId) .ConfigureAwait(false); @@ -1761,7 +1761,7 @@ public override async Task SetFieldValue( var opgaveId = ParseOpgaveId(request.OpgaveId); var arp = await dbContext.AreaRulePlannings - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .FirstOrDefaultAsync(x => x.Id == opgaveId) .ConfigureAwait(false); From 46ebfcc91a3ccd979cbc40d73797baa352c6e3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 6 May 2026 17:49:58 +0200 Subject: [PATCH 04/22] =?UTF-8?q?fix(opgaver-grpc):=20SetFieldValue=20?= =?UTF-8?q?=E2=80=94=20resolve=20FieldValue.Id=20from=20(caseId,=20fieldId?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core.CaseUpdate's wire format is "[fieldValueId]|[value]" where fieldValueId is the FieldValues table ROW PK, not the eForm template Field.Id. The handler was passing request.FieldId (the eForm field id, e.g. 5308). The SDK's _sqlController.FieldValueUpdate then did `db.FieldValues.FirstAsync(x => x.Id == 5308)` — looking up by FieldValue PK, finding either no row or a totally unrelated row. Field values silently never persisted: every targeted FieldValue stayed Value=NULL despite 50-120ms successful SetFieldValue POSTs. Fix: look up the FieldValue row PK for (caseId, eFormFieldId) before building the pipe-pair. If no FieldValue row exists, return NotFound. FieldValue entity has no WorkflowState column so no soft-delete filter is applied. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 28e64be34..1fd52f8f0 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -1799,14 +1799,26 @@ public override async Task SetFieldValue( var sdkDbContext = core.DbContextHelper.GetDbContext(); var language = await sdkDbContext.Languages.FirstAsync().ConfigureAwait(false); - // Single-field update list: "fieldId|value". - // The SDK CaseUpdate API validates field ownership and writes the value; - // CaseUpdateFieldValues then syncs the FieldValues table — same two-call - // pattern as BackendConfigurationCaseService.Update and - // CompliancesGrpcService.UpdateComplianceCase. + // Resolve the FieldValue row PK from the (caseId, eFormFieldId) pair. + // Core.CaseUpdate's wire format is "[fieldValueId]|[value]" where fieldValueId + // is the FieldValues table PK, not the eForm template field.Id. Without this + // lookup the SDK silently fails (or updates a random unrelated FieldValue row + // that happens to have Id == request.FieldId). + var fieldValueRowId = await sdkDbContext.FieldValues + .Where(fv => fv.CaseId == caseId && fv.FieldId == request.FieldId) + .Select(fv => fv.Id) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + + if (fieldValueRowId == 0) + { + throw new RpcException(new Status(StatusCode.NotFound, + $"No FieldValue row exists for case {caseId} field {request.FieldId}.")); + } + var fieldValueList = new List { - $"{request.FieldId}|{request.Value ?? string.Empty}" + $"{fieldValueRowId}|{request.Value ?? string.Empty}" }; try From b1b93fe7d36aac193351d502c8253648c3fd3e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 6 May 2026 18:12:55 +0200 Subject: [PATCH 05/22] fix(opgaver-grpc): filter compliance lookup by current worker's site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetFieldValue/CompleteOpgave/SetComment looked up the compliance row by PlanningId+Deadline only — picking the oldest compliance across ALL sites for the planning. Multi-site plannings (e.g. 3632 with all compliances on SiteId=142) routed device taps from site 130 to write field values against site 142's case 5952 (soft-deleted since 2023). User saw nothing in reportsv2 because no row for their site was touched. Fix: load the current worker's accessible case IDs from the SDK first, then filter compliances to only those whose MicrotingSdkCaseId belongs to that set. Also restore the WorkflowState != Removed OR null filter on SetFieldValue's compliance lookup — the "accept regardless" comment was a design assumption that doesn't hold. The practical edit-then-complete flow has the compliance still active when SetFieldValue lands. Same pattern applied to all 5 handlers (CompleteOpgave, SetFieldValue, SetComment, UploadPhoto, RemovePhoto). No hard deletes. Read-only filter changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 102 +++++++++++++++--- 1 file changed, 87 insertions(+), 15 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 1fd52f8f0..2b0b574b6 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -923,9 +923,25 @@ public override async Task CompleteOpgave( // GetTasksForWeek treats compliance-derived rows as "completable" and // anything else as a future occurrence with no Case to update — so the // absence of a compliance row is a hard error here. + // + // Filter by the current worker's site: multi-site plannings have one + // compliance per site. Without the site filter we pick the OLDEST + // compliance across ALL sites — writing against a stale case that + // doesn't belong to this worker (bug: planning 3632, site 130 vs 142). + var coreForCompliance = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContextForCompliance = coreForCompliance.DbContextHelper.GetDbContext(); + // TODO: if a worker has a very large number of cases this list could grow; + // consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContextForCompliance.Cases + .Where(c => c.SiteId == sdkSiteId) + .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); var compliance = await dbContext.Compliances .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) .OrderBy(x => x.Deadline) .FirstOrDefaultAsync() .ConfigureAwait(false); @@ -953,8 +969,9 @@ public override async Task CompleteOpgave( // shape of CompliancesGrpcService.UpdateComplianceCase but skips the // core.CaseUpdate / CaseUpdateFieldValues calls — the Opgaver flow has // no form fields, so those calls would just iterate empty lists. - var core = await coreHelper.GetCore().ConfigureAwait(false); - var sdkDbContext = core.DbContextHelper.GetDbContext(); + // Reuse the core/sdkDbContext already obtained for the compliance lookup above. + var core = coreForCompliance; + var sdkDbContext = sdkDbContextForCompliance; await compliance.Delete(dbContext).ConfigureAwait(false); @@ -1189,8 +1206,23 @@ public override async Task SetComment( // "noticed leak, scheduled repair". CompleteOpgave keeps the // not-removed filter on its own lookup — re-completing an already // completed task makes no sense there. + // + // Filter by the current worker's site: multi-site plannings have one + // compliance per site. Without the site filter we pick the OLDEST + // compliance across ALL sites — writing against a stale case that + // doesn't belong to this worker (bug: planning 3632, site 130 vs 142). + var coreForCompliance = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContextForCompliance = coreForCompliance.DbContextHelper.GetDbContext(); + // TODO: if a worker has a very large number of cases this list could grow; + // consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContextForCompliance.Cases + .Where(c => c.SiteId == sdkSiteId) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); var compliance = await dbContext.Compliances .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) .OrderBy(x => x.Deadline) .FirstOrDefaultAsync() .ConfigureAwait(false); @@ -1209,8 +1241,9 @@ public override async Task SetComment( }; } - var core = await coreHelper.GetCore().ConfigureAwait(false); - var sdkDbContext = core.DbContextHelper.GetDbContext(); + // Reuse the core/sdkDbContext already obtained for the compliance lookup above. + var core = coreForCompliance; + var sdkDbContext = sdkDbContextForCompliance; var foundCase = await sdkDbContext.Cases .FirstOrDefaultAsync(x => x.Id == compliance.MicrotingSdkCaseId) @@ -1395,8 +1428,23 @@ public override async Task UploadPhoto( // SetComment includes Removed compliance rows so post-completion // edits are possible; do the same here so a worker can attach a // photo to a just-completed opgave. + // + // Filter by the current worker's site: multi-site plannings have one + // compliance per site. Without the site filter we pick the OLDEST + // compliance across ALL sites — writing against a stale case that + // doesn't belong to this worker (bug: planning 3632, site 130 vs 142). + var core = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + // TODO: if a worker has a very large number of cases this list could grow; + // consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContext.Cases + .Where(c => c.SiteId == sdkSiteId) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); var compliance = await dbContext.Compliances .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) .OrderBy(x => x.Deadline) .FirstOrDefaultAsync() .ConfigureAwait(false); @@ -1407,9 +1455,6 @@ public override async Task UploadPhoto( $"Opgave {opgaveId}: no SDK case to attach a photo to (recurrence-only opgaver are not supported in v1).")); } - var core = await coreHelper.GetCore().ConfigureAwait(false); - var sdkDbContext = core.DbContextHelper.GetDbContext(); - var foundCase = await sdkDbContext.Cases .FirstOrDefaultAsync(x => x.Id == compliance.MicrotingSdkCaseId) .ConfigureAwait(false); @@ -1582,8 +1627,22 @@ public override async Task RemovePhoto( "Caller has no PropertyWorker access to the opgave's property.")); } + // Filter by the current worker's site: multi-site plannings have one + // compliance per site. Without the site filter we pick the OLDEST + // compliance across ALL sites — writing against a stale case that + // doesn't belong to this worker (bug: planning 3632, site 130 vs 142). + var core = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + // TODO: if a worker has a very large number of cases this list could grow; + // consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContext.Cases + .Where(c => c.SiteId == sdkSiteId) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); var compliance = await dbContext.Compliances .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) .OrderBy(x => x.Deadline) .FirstOrDefaultAsync() .ConfigureAwait(false); @@ -1594,9 +1653,6 @@ public override async Task RemovePhoto( return new RemovePhotoResponse(); } - var core = await coreHelper.GetCore().ConfigureAwait(false); - var sdkDbContext = core.DbContextHelper.GetDbContext(); - var foundCase = await sdkDbContext.Cases .FirstOrDefaultAsync(x => x.Id == compliance.MicrotingSdkCaseId) .ConfigureAwait(false); @@ -1779,10 +1835,29 @@ public override async Task SetFieldValue( "Caller has no PropertyWorker access to the opgave's property.")); } - // Accept the compliance row regardless of WorkflowState — same rationale - // as SetComment: a worker can write field values on a just-completed task. + // Resolve the compliance for this ARP's planning, filtered to the + // current worker's site. Without the site filter, multi-site plannings + // hand back the OLDEST compliance across all sites — leading to writes + // against stale cases that don't belong to this worker. + // + // WorkflowState != Removed is restored here: the "accept regardless" + // comment was a design assumption that doesn't hold. The practical + // edit-then-complete flow has the compliance still active when + // SetFieldValue lands. If it's been soft-deleted, we should NOT write to it. + var core = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + // TODO: if a worker has a very large number of cases this list could grow; + // consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContext.Cases + .Where(c => c.SiteId == sdkSiteId) + .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); var compliance = await dbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) .OrderBy(x => x.Deadline) .FirstOrDefaultAsync() .ConfigureAwait(false); @@ -1794,9 +1869,6 @@ public override async Task SetFieldValue( } var caseId = compliance.MicrotingSdkCaseId; - - var core = await coreHelper.GetCore().ConfigureAwait(false); - var sdkDbContext = core.DbContextHelper.GetDbContext(); var language = await sdkDbContext.Languages.FirstAsync().ConfigureAwait(false); // Resolve the FieldValue row PK from the (caseId, eFormFieldId) pair. From dda33dbf19686cc19fbe603f505ca617c6edb3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 6 May 2026 19:52:54 +0200 Subject: [PATCH 06/22] feat(opgaver-grpc): consume client-echoed compliance_id + sdk_case_id Replaces the fuzzy OrderBy(Deadline).FirstOrDefaultAsync compliance lookup in CompleteOpgave / SetComment / UploadPhoto / RemovePhoto / SetFieldValue with a deterministic PK lookup against request.ComplianceId, validated against the ARP's ItemPlanningId. The Index emit paths (ListOpgaver, StreamOpgaveChanges' poll loader) now surface Compliance.Id + MicrotingSdkCaseId on every Opgave so the mobile client can persist and round-trip them on every write. Legacy fallback is intentionally preserved on every site: when request.ComplianceId == 0 (older Flutter build with the field unset) the previous site-filtered ordered query still runs, so in-flight outbox payloads queued before this contract landed continue to drain. Proto: adds compliance_id + microting_sdk_case_id (int64) to Opgave, CompleteOpgaveRequest, SetCommentRequest, UploadPhotoMeta, RemovePhotoRequest, SetFieldValueRequest. Field numbers are unique within each message; existing numbers are unchanged so the wire contract is forward + backward compatible. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Protos/opgaver.proto | 28 ++ .../GrpcServices/OpgaverGrpcService.cs | 286 ++++++++++++------ 2 files changed, 220 insertions(+), 94 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto index 3052b5111..03a00014d 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto @@ -30,6 +30,13 @@ message UploadPhotoMeta { int32 slot = 2; string content_type = 3; int64 client_ts_unix = 4; + // Server-assigned PK of the Compliance row this opgave was emitted from + // (see Index handler). 0 means "unknown" — legacy clients that pre-date + // this field; server falls back to the site-filtered fuzzy lookup in + // that case so in-flight outbox payloads keep draining. + int64 compliance_id = 5; + // Server-assigned PK of the SDK Case row backing this opgave. 0 = legacy. + int64 microting_sdk_case_id = 6; } message UploadPhotoResponse { string storage_id = 1; @@ -39,6 +46,9 @@ message RemovePhotoRequest { string opgave_id = 1; int32 slot = 2; int64 client_ts_unix = 3; + // See UploadPhotoMeta.compliance_id / microting_sdk_case_id. 0 = legacy. + int64 compliance_id = 4; + int64 microting_sdk_case_id = 5; } message RemovePhotoResponse {} @@ -47,6 +57,9 @@ message CompleteOpgaveRequest { bool completed = 2; string completed_by = 3; int64 client_ts_unix = 4; + // See UploadPhotoMeta.compliance_id / microting_sdk_case_id. 0 = legacy. + int64 compliance_id = 5; + int64 microting_sdk_case_id = 6; } message CompleteOpgaveResponse { Opgave opgave = 1; } @@ -54,6 +67,9 @@ message SetCommentRequest { string opgave_id = 1; string text = 2; int64 client_ts_unix = 3; + // See UploadPhotoMeta.compliance_id / microting_sdk_case_id. 0 = legacy. + int64 compliance_id = 4; + int64 microting_sdk_case_id = 5; } message SetCommentResponse { Opgave opgave = 1; } @@ -62,6 +78,9 @@ message SetFieldValueRequest { int32 field_id = 2; string value = 3; int64 client_ts_unix = 4; + // See UploadPhotoMeta.compliance_id / microting_sdk_case_id. 0 = legacy. + int64 compliance_id = 5; + int64 microting_sdk_case_id = 6; } message SetFieldValueResponse { Opgave opgave = 1; } @@ -122,6 +141,15 @@ message Opgave { // Form-field structure + current values for the eForm template backing // this opgave. Empty when no Case exists yet (recurrence-only tasks). repeated FormField fields = 15; + // Server-assigned PK of the Compliance row this opgave was emitted from. + // Round-tripped through the Flutter Drift cache and back on every write + // so the server can resolve compliance + sdk case deterministically + // (no fuzzy OrderBy(Deadline).First() lookup). 0 = recurrence-only task + // with no backing compliance yet (or legacy server pre-dating this field). + int64 compliance_id = 16; + // Server-assigned PK of the SDK Case row backing this opgave. 0 means + // "no backing case" (recurrence-only) or a legacy server. + int64 microting_sdk_case_id = 17; } message Attachment { diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 2b0b574b6..9c066b655 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -21,6 +21,7 @@ using Microting.eForm.Infrastructure.Models; using Microting.eFormApi.BasePn.Abstractions; using Microting.EformBackendConfigurationBase.Infrastructure.Data; +using Microting.EformBackendConfigurationBase.Infrastructure.Data.Entities; using Microting.ItemsPlanningBase.Infrastructure.Data; using SdkUploadedData = Microting.eForm.Infrastructure.Data.Entities.UploadedData; using SdkDataItem = Microting.eForm.Infrastructure.Models.DataItem; @@ -237,7 +238,17 @@ public override async Task ListOpgaver( CompletedBy = string.Empty, DescriptionHtml = task.DescriptionHtml ?? string.Empty, Comment = comment, - EformId = task.EformId ?? 0 + EformId = task.EformId ?? 0, + // Stable-identity round-trip: emit the compliance + sdk case PKs + // so the Flutter client can persist them and echo them back on + // every write. Server then looks them up by Id directly, + // avoiding the fuzzy OrderBy(Deadline).First() fallback (which + // is non-deterministic when one planning has multiple + // compliances on the same site). 0 = recurrence-only task with + // no backing case yet — kept legacy fallback handles that + // path safely. + ComplianceId = task.ComplianceId ?? 0, + MicrotingSdkCaseId = task.SdkCaseId ?? 0 // updated_at: Timestamp default (zero) — no source field in CalendarTaskResponseModel. }; @@ -850,7 +861,11 @@ private async Task> LoadOpgaverAsync( CompletedBy = string.Empty, DescriptionHtml = task.DescriptionHtml ?? string.Empty, Comment = comment, - EformId = task.EformId ?? 0 + EformId = task.EformId ?? 0, + // See comment in ListOpgaver: stable-identity round-trip so + // write handlers can resolve compliance + sdk case directly. + ComplianceId = task.ComplianceId ?? 0, + MicrotingSdkCaseId = task.SdkCaseId ?? 0 }; PopulateAttachments(opgave, envelope); @@ -924,27 +939,52 @@ public override async Task CompleteOpgave( // anything else as a future occurrence with no Case to update — so the // absence of a compliance row is a hard error here. // - // Filter by the current worker's site: multi-site plannings have one - // compliance per site. Without the site filter we pick the OLDEST - // compliance across ALL sites — writing against a stale case that - // doesn't belong to this worker (bug: planning 3632, site 130 vs 142). + // Stable-identity path: when the client echoes the compliance_id from + // the Opgave it received, look the row up by PK directly. This is + // 100% deterministic — no OrderBy(Deadline).First() ambiguity when + // one planning has multiple compliances on the same site (recurring + // tasks, historical rows, overlapping windows). We still validate + // the row matches the ARP's planning before trusting it. + // + // Legacy fallback (compliance_id == 0): older app builds that pre- + // date this contract. Filter by the current worker's site: multi- + // site plannings have one compliance per site. Without the site + // filter we pick the OLDEST compliance across ALL sites — writing + // against a stale case that doesn't belong to this worker (bug: + // planning 3632, site 130 vs 142). var coreForCompliance = await coreHelper.GetCore().ConfigureAwait(false); var sdkDbContextForCompliance = coreForCompliance.DbContextHelper.GetDbContext(); - // TODO: if a worker has a very large number of cases this list could grow; - // consider a JOIN-based query if perf becomes an issue. - var validCaseIdsForSite = await sdkDbContextForCompliance.Cases - .Where(c => c.SiteId == sdkSiteId) - .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) - .Select(c => c.Id) - .ToListAsync() - .ConfigureAwait(false); - var compliance = await dbContext.Compliances - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) - .Where(x => x.PlanningId == arp.ItemPlanningId) - .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + Compliance? compliance; + if (request.ComplianceId > 0) + { + compliance = await dbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) + .Where(x => x.Id == (int)request.ComplianceId) + .Where(x => x.PlanningId == arp.ItemPlanningId) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } + else + { + // Legacy fuzzy lookup — DO NOT remove. Older outbox payloads + // queued before this contract landed will still drain through + // here while the device cache catches up. + // TODO: if a worker has a very large number of cases this list + // could grow; consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContextForCompliance.Cases + .Where(c => c.SiteId == sdkSiteId) + .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); + compliance = await dbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) + .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) + .OrderBy(x => x.Deadline) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } if (compliance == null) { @@ -1207,25 +1247,39 @@ public override async Task SetComment( // not-removed filter on its own lookup — re-completing an already // completed task makes no sense there. // - // Filter by the current worker's site: multi-site plannings have one - // compliance per site. Without the site filter we pick the OLDEST - // compliance across ALL sites — writing against a stale case that - // doesn't belong to this worker (bug: planning 3632, site 130 vs 142). + // Stable-identity path: when the client echoes the compliance_id + // from the Opgave, look up by PK directly (deterministic — see + // CompleteOpgave for full rationale). Legacy fallback (compliance_id + // == 0) keeps the existing site-filtered fuzzy lookup so older + // outbox payloads still drain. var coreForCompliance = await coreHelper.GetCore().ConfigureAwait(false); var sdkDbContextForCompliance = coreForCompliance.DbContextHelper.GetDbContext(); - // TODO: if a worker has a very large number of cases this list could grow; - // consider a JOIN-based query if perf becomes an issue. - var validCaseIdsForSite = await sdkDbContextForCompliance.Cases - .Where(c => c.SiteId == sdkSiteId) - .Select(c => c.Id) - .ToListAsync() - .ConfigureAwait(false); - var compliance = await dbContext.Compliances - .Where(x => x.PlanningId == arp.ItemPlanningId) - .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + Compliance? compliance; + if (request.ComplianceId > 0) + { + compliance = await dbContext.Compliances + .Where(x => x.Id == (int)request.ComplianceId) + .Where(x => x.PlanningId == arp.ItemPlanningId) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } + else + { + // Legacy fuzzy lookup — DO NOT remove. See CompleteOpgave. + // TODO: if a worker has a very large number of cases this list + // could grow; consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContextForCompliance.Cases + .Where(c => c.SiteId == sdkSiteId) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); + compliance = await dbContext.Compliances + .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) + .OrderBy(x => x.Deadline) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } // Truly orphaned task (no compliance row at all → no SDK Case ever // existed). Echo the comment back in a synthesised minimal Opgave so @@ -1429,25 +1483,39 @@ public override async Task UploadPhoto( // edits are possible; do the same here so a worker can attach a // photo to a just-completed opgave. // - // Filter by the current worker's site: multi-site plannings have one - // compliance per site. Without the site filter we pick the OLDEST - // compliance across ALL sites — writing against a stale case that - // doesn't belong to this worker (bug: planning 3632, site 130 vs 142). + // Stable-identity path: client echoes the compliance_id from the + // Opgave it received; we look up by PK directly. Legacy fallback + // (compliance_id == 0) keeps the site-filtered fuzzy lookup so + // older outbox payloads still drain. See CompleteOpgave for the + // full rationale. var core = await coreHelper.GetCore().ConfigureAwait(false); var sdkDbContext = core.DbContextHelper.GetDbContext(); - // TODO: if a worker has a very large number of cases this list could grow; - // consider a JOIN-based query if perf becomes an issue. - var validCaseIdsForSite = await sdkDbContext.Cases - .Where(c => c.SiteId == sdkSiteId) - .Select(c => c.Id) - .ToListAsync() - .ConfigureAwait(false); - var compliance = await dbContext.Compliances - .Where(x => x.PlanningId == arp.ItemPlanningId) - .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + Compliance? compliance; + if (meta.ComplianceId > 0) + { + compliance = await dbContext.Compliances + .Where(x => x.Id == (int)meta.ComplianceId) + .Where(x => x.PlanningId == arp.ItemPlanningId) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } + else + { + // Legacy fuzzy lookup — DO NOT remove. See CompleteOpgave. + // TODO: if a worker has a very large number of cases this list + // could grow; consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContext.Cases + .Where(c => c.SiteId == sdkSiteId) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); + compliance = await dbContext.Compliances + .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) + .OrderBy(x => x.Deadline) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } if (compliance == null) { @@ -1627,25 +1695,38 @@ public override async Task RemovePhoto( "Caller has no PropertyWorker access to the opgave's property.")); } - // Filter by the current worker's site: multi-site plannings have one - // compliance per site. Without the site filter we pick the OLDEST - // compliance across ALL sites — writing against a stale case that - // doesn't belong to this worker (bug: planning 3632, site 130 vs 142). + // Stable-identity path: client echoes the compliance_id from the + // Opgave; PK lookup is deterministic. Legacy fallback (== 0) keeps + // the site-filtered fuzzy lookup so older outbox payloads still + // drain. See CompleteOpgave for full rationale. var core = await coreHelper.GetCore().ConfigureAwait(false); var sdkDbContext = core.DbContextHelper.GetDbContext(); - // TODO: if a worker has a very large number of cases this list could grow; - // consider a JOIN-based query if perf becomes an issue. - var validCaseIdsForSite = await sdkDbContext.Cases - .Where(c => c.SiteId == sdkSiteId) - .Select(c => c.Id) - .ToListAsync() - .ConfigureAwait(false); - var compliance = await dbContext.Compliances - .Where(x => x.PlanningId == arp.ItemPlanningId) - .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + Compliance? compliance; + if (request.ComplianceId > 0) + { + compliance = await dbContext.Compliances + .Where(x => x.Id == (int)request.ComplianceId) + .Where(x => x.PlanningId == arp.ItemPlanningId) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } + else + { + // Legacy fuzzy lookup — DO NOT remove. See CompleteOpgave. + // TODO: if a worker has a very large number of cases this list + // could grow; consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContext.Cases + .Where(c => c.SiteId == sdkSiteId) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); + compliance = await dbContext.Compliances + .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) + .OrderBy(x => x.Deadline) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } if (compliance == null) { @@ -1835,32 +1916,49 @@ public override async Task SetFieldValue( "Caller has no PropertyWorker access to the opgave's property.")); } - // Resolve the compliance for this ARP's planning, filtered to the - // current worker's site. Without the site filter, multi-site plannings - // hand back the OLDEST compliance across all sites — leading to writes - // against stale cases that don't belong to this worker. + // Resolve the compliance for this ARP's planning. // - // WorkflowState != Removed is restored here: the "accept regardless" - // comment was a design assumption that doesn't hold. The practical + // Stable-identity path: client echoes the compliance_id from the + // Opgave it received; we look up by PK directly (deterministic — + // see CompleteOpgave for the full rationale). Legacy fallback + // (compliance_id == 0) preserves the existing site-filtered fuzzy + // lookup so older outbox payloads still drain. + // + // WorkflowState != Removed is enforced on both paths: the practical // edit-then-complete flow has the compliance still active when - // SetFieldValue lands. If it's been soft-deleted, we should NOT write to it. + // SetFieldValue lands. If it's been soft-deleted, we should NOT + // write to it. var core = await coreHelper.GetCore().ConfigureAwait(false); var sdkDbContext = core.DbContextHelper.GetDbContext(); - // TODO: if a worker has a very large number of cases this list could grow; - // consider a JOIN-based query if perf becomes an issue. - var validCaseIdsForSite = await sdkDbContext.Cases - .Where(c => c.SiteId == sdkSiteId) - .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) - .Select(c => c.Id) - .ToListAsync() - .ConfigureAwait(false); - var compliance = await dbContext.Compliances - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) - .Where(x => x.PlanningId == arp.ItemPlanningId) - .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + Compliance? compliance; + if (request.ComplianceId > 0) + { + compliance = await dbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) + .Where(x => x.Id == (int)request.ComplianceId) + .Where(x => x.PlanningId == arp.ItemPlanningId) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } + else + { + // Legacy fuzzy lookup — DO NOT remove. See CompleteOpgave. + // TODO: if a worker has a very large number of cases this list + // could grow; consider a JOIN-based query if perf becomes an issue. + var validCaseIdsForSite = await sdkDbContext.Cases + .Where(c => c.SiteId == sdkSiteId) + .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); + compliance = await dbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) + .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) + .OrderBy(x => x.Deadline) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } if (compliance == null) { From c26378004e01a8cf8059db34d3c2557fc7ad7dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 07:32:58 +0200 Subject: [PATCH 07/22] feat(opgaver): filter non-actionable opgaver from calendar emit Compliance rows whose Case is removed (missed deadline) or completed (Status=100) should not surface on the worker's mobile calendar. Updates GetTasksForWeek to skip emit when the Compliance is removed, the Case is removed, or the Case is already completed. Dedup gate refined to only skip recurrence rendering for plannings with an *actionable* compliance in the week. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BackendConfigurationCalendarService.cs | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs index f83afe408..53f2b868f 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs @@ -58,13 +58,65 @@ public async Task>> GetTasks .FirstOrDefaultAsync(); var defaultBoardId = defaultBoard?.Id; - // Pre-load compliance dates to avoid duplicates between occurrence expansion and compliances - var compliancesInWeek = await backendConfigurationPnDbContext.Compliances - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + // Pre-load compliance dates to avoid duplicates between occurrence expansion and compliances. + // We deliberately keep ALL non-removed compliances here (the original prefetch set) and + // then filter the subset that is *actionable* below: a compliance is actionable only if + // its backing SDK Case still exists, is not soft-deleted, and is not yet completed + // (Status != 100). Non-actionable compliance rows must NOT be emitted to the worker + // because the corresponding write handlers ("complete", "comment", etc.) have nothing + // to bind to and will fail. Treat WorkflowState NULL as "not removed" — pre-existing + // project rule, see e.g. similar guards across this service. + var compliancesInWeekAll = await backendConfigurationPnDbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .Where(x => x.PropertyId == requestModel.PropertyId) .Where(x => x.Deadline >= weekStart && x.Deadline <= weekEnd) .ToListAsync(); - // Build sets for dedup: by exact date and by planningId (any compliance in week) + + // Batch-load the SDK Cases backing those compliances so we can decide actionability + // without an N+1 round-trip per compliance row. + var complianceSdkCaseIds = compliancesInWeekAll + .Select(c => c.MicrotingSdkCaseId) + .Where(id => id > 0) + .Distinct() + .ToList(); + var sdkCore = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContextForCalendar = sdkCore.DbContextHelper.GetDbContext(); + var sdkCasesById = await sdkDbContextForCalendar.Cases + .Where(c => complianceSdkCaseIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + + bool IsComplianceActionable(Compliance compliance) + { + // 1. Compliance row itself must not be soft-deleted (NULL == not removed). + if (compliance.WorkflowState == Constants.WorkflowStates.Removed) + return false; + + // 2. Backing SDK Case must exist and not be soft-deleted (NULL == not removed). + if (compliance.MicrotingSdkCaseId <= 0) + return false; + if (!sdkCasesById.TryGetValue(compliance.MicrotingSdkCaseId, out var sdkCase) || sdkCase == null) + return false; + if (sdkCase.WorkflowState == Constants.WorkflowStates.Removed) + return false; + + // 3. SDK Case must not be already completed. + // Status == 100 is the canonical "done" code (see e.g. + // BackendConfigurationCompliancesService.cs:258, BackendConfigurationCaseService.cs:73, + // BackendConfigurationReportService.cs:84). + if (sdkCase.Status == 100) + return false; + + return true; + } + + // The actionable subset is what we actually emit AND what governs the recurrence-dedup + // gate below. If a planning's only in-week compliance is non-actionable (missed deadline + // or already completed), the recurrence path SHOULD still fire so the worker doesn't + // lose visibility on a NEXT live rotation in the same week. + var compliancesInWeek = compliancesInWeekAll + .Where(IsComplianceActionable) + .ToList(); + // Build sets for dedup: by exact date and by planningId (any actionable compliance in week) var complianceDateSet = new HashSet( compliancesInWeek.Select(c => $"{c.PlanningId}:{c.Deadline:yyyy-MM-dd}")); var compliancePlanningIdsInWeek = new HashSet( From 3802ece0a3105c526fc28a2469c80ce038ce0102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 07:41:16 +0200 Subject: [PATCH 08/22] refactor(opgaver): scope actionable-only filter to mobile worker calls The previous commit applied the actionable-only filter to all callers of GetTasksForWeek including the web admin's calendar and CalendarGrpcService, which would have hidden missed/completed rotations from the admin UI. Adds an opt-in ActionableOnly flag on CalendarTaskRequestModel; only the 5 mobile worker call sites in OpgaverGrpcService set it. Default behavior for all other callers is bit-identical to pre-c2637800. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Calendar/CalendarTaskRequestModel.cs | 14 ++ .../BackendConfigurationCalendarService.cs | 133 +++++++++++------- .../GrpcServices/OpgaverGrpcService.cs | 15 +- 3 files changed, 103 insertions(+), 59 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskRequestModel.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskRequestModel.cs index 5c1ad8378..b6bb0cb41 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskRequestModel.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskRequestModel.cs @@ -10,4 +10,18 @@ public class CalendarTaskRequestModel public List BoardIds { get; set; } = []; public List TagNames { get; set; } = []; public List SiteIds { get; set; } = []; + + /// + /// When true, the calendar emits only *actionable* compliance rows for the requested + /// week — i.e. compliances whose backing SDK Case still exists, is not soft-deleted, + /// and is not yet completed (Status != 100). This is intended for the mobile-worker + /// gRPC path (OpgaverGrpcService) where non-actionable rows have no write + /// handler to bind to and would just clutter the worker's view. + /// + /// Default false preserves the historical behavior used by the angular admin + /// calendar (CalendarController) and other gRPC consumers + /// (CalendarGrpcService): all in-week compliances surface, including missed + /// and completed ones, so the admin can audit the full week. + /// + public bool ActionableOnly { get; set; } = false; } diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs index 53f2b868f..563099306 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs @@ -59,64 +59,89 @@ public async Task>> GetTasks var defaultBoardId = defaultBoard?.Id; // Pre-load compliance dates to avoid duplicates between occurrence expansion and compliances. - // We deliberately keep ALL non-removed compliances here (the original prefetch set) and - // then filter the subset that is *actionable* below: a compliance is actionable only if - // its backing SDK Case still exists, is not soft-deleted, and is not yet completed - // (Status != 100). Non-actionable compliance rows must NOT be emitted to the worker - // because the corresponding write handlers ("complete", "comment", etc.) have nothing - // to bind to and will fail. Treat WorkflowState NULL as "not removed" — pre-existing - // project rule, see e.g. similar guards across this service. - var compliancesInWeekAll = await backendConfigurationPnDbContext.Compliances - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) - .Where(x => x.PropertyId == requestModel.PropertyId) - .Where(x => x.Deadline >= weekStart && x.Deadline <= weekEnd) - .ToListAsync(); + // + // Two modes, gated on requestModel.ActionableOnly: + // + // * ActionableOnly == false (default; angular admin REST calendar + + // CalendarGrpcService): emit ALL non-removed compliances in the week, including + // missed deadlines and already-completed ones. Bit-identical to pre-c2637800. + // + // * ActionableOnly == true (mobile worker via OpgaverGrpcService): emit only + // compliances whose backing SDK Case still exists, is not soft-deleted, and is + // not yet completed (Status != 100). Non-actionable compliance rows must NOT be + // emitted to the worker because the corresponding write handlers ("complete", + // "comment", etc.) have nothing to bind to and will fail. + List compliancesInWeek; + if (!requestModel.ActionableOnly) + { + // Default branch — bit-identical to the pre-c2637800 prefetch. + compliancesInWeek = await backendConfigurationPnDbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.PropertyId == requestModel.PropertyId) + .Where(x => x.Deadline >= weekStart && x.Deadline <= weekEnd) + .ToListAsync(); + } + else + { + // Mobile-worker branch — actionable subset only. + // Treat WorkflowState NULL as "not removed" here (pre-existing project rule + // applied across this service); the default branch above keeps its original + // strict `!= Removed` semantics so non-mobile callers remain bit-identical. + var compliancesInWeekAll = await backendConfigurationPnDbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) + .Where(x => x.PropertyId == requestModel.PropertyId) + .Where(x => x.Deadline >= weekStart && x.Deadline <= weekEnd) + .ToListAsync(); - // Batch-load the SDK Cases backing those compliances so we can decide actionability - // without an N+1 round-trip per compliance row. - var complianceSdkCaseIds = compliancesInWeekAll - .Select(c => c.MicrotingSdkCaseId) - .Where(id => id > 0) - .Distinct() - .ToList(); - var sdkCore = await coreHelper.GetCore().ConfigureAwait(false); - var sdkDbContextForCalendar = sdkCore.DbContextHelper.GetDbContext(); - var sdkCasesById = await sdkDbContextForCalendar.Cases - .Where(c => complianceSdkCaseIds.Contains(c.Id)) - .ToDictionaryAsync(c => c.Id); + // Batch-load the SDK Cases backing those compliances so we can decide + // actionability without an N+1 round-trip per compliance row. + var complianceSdkCaseIds = compliancesInWeekAll + .Select(c => c.MicrotingSdkCaseId) + .Where(id => id > 0) + .Distinct() + .ToList(); + var sdkCore = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContextForCalendar = sdkCore.DbContextHelper.GetDbContext(); + var sdkCasesById = await sdkDbContextForCalendar.Cases + .Where(c => complianceSdkCaseIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + + bool IsComplianceActionable(Compliance compliance) + { + // 1. Compliance row itself must not be soft-deleted (NULL == not removed). + if (compliance.WorkflowState == Constants.WorkflowStates.Removed) + return false; + + // 2. Backing SDK Case must exist and not be soft-deleted (NULL == not removed). + if (compliance.MicrotingSdkCaseId <= 0) + return false; + if (!sdkCasesById.TryGetValue(compliance.MicrotingSdkCaseId, out var sdkCase) || sdkCase == null) + return false; + if (sdkCase.WorkflowState == Constants.WorkflowStates.Removed) + return false; + + // 3. SDK Case must not be already completed. + // Status == 100 is the canonical "done" code (see e.g. + // BackendConfigurationCompliancesService.cs:258, BackendConfigurationCaseService.cs:73, + // BackendConfigurationReportService.cs:84). + if (sdkCase.Status == 100) + return false; + + return true; + } - bool IsComplianceActionable(Compliance compliance) - { - // 1. Compliance row itself must not be soft-deleted (NULL == not removed). - if (compliance.WorkflowState == Constants.WorkflowStates.Removed) - return false; - - // 2. Backing SDK Case must exist and not be soft-deleted (NULL == not removed). - if (compliance.MicrotingSdkCaseId <= 0) - return false; - if (!sdkCasesById.TryGetValue(compliance.MicrotingSdkCaseId, out var sdkCase) || sdkCase == null) - return false; - if (sdkCase.WorkflowState == Constants.WorkflowStates.Removed) - return false; - - // 3. SDK Case must not be already completed. - // Status == 100 is the canonical "done" code (see e.g. - // BackendConfigurationCompliancesService.cs:258, BackendConfigurationCaseService.cs:73, - // BackendConfigurationReportService.cs:84). - if (sdkCase.Status == 100) - return false; - - return true; + // The actionable subset is what we actually emit AND what governs the + // recurrence-dedup gate below. If a planning's only in-week compliance is + // non-actionable (missed deadline or already completed), the recurrence path + // SHOULD still fire so the worker doesn't lose visibility on a NEXT live + // rotation in the same week. + compliancesInWeek = compliancesInWeekAll + .Where(IsComplianceActionable) + .ToList(); } - // The actionable subset is what we actually emit AND what governs the recurrence-dedup - // gate below. If a planning's only in-week compliance is non-actionable (missed deadline - // or already completed), the recurrence path SHOULD still fire so the worker doesn't - // lose visibility on a NEXT live rotation in the same week. - var compliancesInWeek = compliancesInWeekAll - .Where(IsComplianceActionable) - .ToList(); - // Build sets for dedup: by exact date and by planningId (any actionable compliance in week) + // Build sets for dedup: by exact date and by planningId (any in-week compliance, + // or — when ActionableOnly is set — any *actionable* in-week compliance). var complianceDateSet = new HashSet( compliancesInWeek.Select(c => $"{c.PlanningId}:{c.Deadline:yyyy-MM-dd}")); var compliancePlanningIdsInWeek = new HashSet( diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 9c066b655..45c5cc216 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -193,7 +193,8 @@ public override async Task ListOpgaver( WeekEnd = request.ToDateKey ?? string.Empty, BoardIds = TryParseBoardIds(request.TavleId), TagNames = [], - SiteIds = [] + SiteIds = [], + ActionableOnly = true }; var result = await calendarService.GetTasksForWeek(model); @@ -826,7 +827,8 @@ private async Task> LoadOpgaverAsync( WeekEnd = windowEnd.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), BoardIds = boardFilter, TagNames = [], - SiteIds = [] + SiteIds = [], + ActionableOnly = true }; var result = await calendarService.GetTasksForWeek(model).ConfigureAwait(false); @@ -1085,7 +1087,8 @@ public override async Task CompleteOpgave( WeekEnd = dayKey, BoardIds = [], TagNames = [], - SiteIds = [] + SiteIds = [], + ActionableOnly = true }).ConfigureAwait(false); var refreshedTask = refreshed.Success && refreshed.Model != null @@ -1340,7 +1343,8 @@ public override async Task SetComment( WeekEnd = dayKey, BoardIds = [], TagNames = [], - SiteIds = [] + SiteIds = [], + ActionableOnly = true }).ConfigureAwait(false); var refreshedTask = refreshed.Success && refreshed.Model != null @@ -2017,7 +2021,8 @@ public override async Task SetFieldValue( WeekEnd = dayKey, BoardIds = [], TagNames = [], - SiteIds = [] + SiteIds = [], + ActionableOnly = true }).ConfigureAwait(false); var refreshedTask = refreshed.Success && refreshed.Model != null From 7703eb52c2bf997755b6198085de8848597b6ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 08:11:43 +0200 Subject: [PATCH 09/22] fix(opgaver): make CompleteOpgave idempotent for Completed=false The previous Unimplemented throw triggered an infinite retry loop in the flutter outbox drainer when re-tapping an already-completed row. The handler now returns the current authoritative Opgave state with no DB writes, letting flutter merge the response and converge to server truth. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 189 +++++++++++++++++- 1 file changed, 181 insertions(+), 8 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 45c5cc216..cbaa5f7c1 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -903,18 +903,23 @@ public override async Task CompleteOpgave( CompleteOpgaveRequest request, ServerCallContext context) { - // Only "complete" semantics are supported in v1. The JSON-side - // ToggleComplete is itself a TODO; an explicit un-complete flow will - // require new entity work (re-creating the compliance row), which is - // out of scope for this PR. + var opgaveId = ParseOpgaveId(request.OpgaveId); + + // Idempotent re-tap path. The flutter UI sends `!o.completed` when a + // worker re-taps a row, so a row whose local state already says + // "completed" arrives here as Completed=false. There is nothing to + // un-complete (un-completion would require re-creating the compliance + // row, which is out of scope), so we treat this as a no-op AND return + // the current authoritative server state so the client can reconcile + // its optimistic flip back to the truth. Without this, an Unimplemented + // throw triggers an infinite retry loop in the flutter outbox drainer + // because StatusCode.Unimplemented is not in its permanent-error set. if (!request.Completed) { - throw new RpcException(new Status(StatusCode.Unimplemented, - "Un-completing an opgave is not supported yet.")); + return await BuildIdempotentCompleteOpgaveResponse(opgaveId, request) + .ConfigureAwait(false); } - var opgaveId = ParseOpgaveId(request.OpgaveId); - // Look up the AreaRulePlanning to learn its property + ItemPlanningId. // ItemPlanningId is the join key into Compliances.PlanningId. var arp = await dbContext.AreaRulePlannings @@ -1131,6 +1136,174 @@ public override async Task CompleteOpgave( return new CompleteOpgaveResponse { Opgave = opgave }; } + /// + /// Idempotent no-op handler for CompleteOpgave when the client + /// sends completed=false. This happens when the flutter UI re-taps + /// an already-completed row (it sends !o.completed). Returns the + /// current authoritative state so the client can reconcile its optimistic + /// flip; performs NO database writes. + /// + /// Three observable outcomes: + /// + /// + /// ARP missing → NotFound (the flutter + /// _isPermanent set already handles this — the row drops out + /// of the outbox and into the conflict modal). + /// Compliance gone (row was already normally + /// completed) → return a synthesized minimal Opgave with + /// Completed=true; the client treats that as "no longer + /// actionable" and removes the row from Drift. + /// Compliance + Case both alive (anomaly: a still- + /// actionable row is being un-completed) → return whatever the + /// calendar query says about that row today. No DB writes. + /// + /// + private async Task BuildIdempotentCompleteOpgaveResponse( + int opgaveId, CompleteOpgaveRequest request) + { + var arp = await dbContext.AreaRulePlannings + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) + .FirstOrDefaultAsync(x => x.Id == opgaveId) + .ConfigureAwait(false); + + if (arp == null) + { + // Mirrors the main path. flutter _isPermanent treats NotFound as + // permanent so the outbox row resolves into the conflict modal + // rather than looping. + throw new RpcException(new Status(StatusCode.NotFound, + $"Opgave {opgaveId} not found.")); + } + + var sdkSiteId = await siteResolver.GetSdkSiteIdAsync().ConfigureAwait(false); + if (!await userPropertyAccess.HasAccessAsync(sdkSiteId, arp.PropertyId) + .ConfigureAwait(false)) + { + throw new RpcException(new Status(StatusCode.PermissionDenied, + "Caller has no PropertyWorker access to the opgave's property.")); + } + + // Resolve the Compliance row using the same PK / fallback pattern as + // the main CompleteOpgave path so behaviour is consistent. Filter on + // not-removed: a soft-deleted compliance is the signal that the row + // was already completed via the canonical path. + var coreForCompliance = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContextForCompliance = coreForCompliance.DbContextHelper.GetDbContext(); + Compliance? compliance; + if (request.ComplianceId > 0) + { + compliance = await dbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) + .Where(x => x.Id == (int)request.ComplianceId) + .Where(x => x.PlanningId == arp.ItemPlanningId) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } + else + { + // Legacy fuzzy lookup — same shape as main CompleteOpgave. + var validCaseIdsForSite = await sdkDbContextForCompliance.Cases + .Where(c => c.SiteId == sdkSiteId) + .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) + .Select(c => c.Id) + .ToListAsync() + .ConfigureAwait(false); + compliance = await dbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) + .Where(x => x.PlanningId == arp.ItemPlanningId) + .Where(x => validCaseIdsForSite.Contains(x.MicrotingSdkCaseId)) + .OrderBy(x => x.Deadline) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } + + var nowUtc = request.ClientTsUnix > 0 + ? DateTimeOffset.FromUnixTimeSeconds(request.ClientTsUnix).UtcDateTime + : DateTime.UtcNow; + + if (compliance == null) + { + // No live compliance row — the row was already completed (or never + // had one). Return Completed=true so the flutter client drops it + // from Drift via the empty/zero-id "no longer actionable" path. + return new CompleteOpgaveResponse + { + Opgave = new Opgave + { + Id = opgaveId.ToString(CultureInfo.InvariantCulture), + EjendomId = arp.PropertyId.ToString(CultureInfo.InvariantCulture), + TavleId = string.Empty, + PlanDayKey = nowUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + PlannedAt = string.Empty, + TaskText = string.Empty, + CalendarColor = string.Empty, + Completed = true, + CompletedBy = request.CompletedBy ?? string.Empty, + DescriptionHtml = string.Empty, + Comment = string.Empty + } + }; + } + + // Compliance still alive — anomaly: a still-actionable row is being + // re-tapped to un-complete. Return the row's current state from the + // calendar query so the client converges to server truth (no DB + // writes — un-completion is intentionally not implemented). + var dayKey = (compliance.Deadline != default ? compliance.Deadline : nowUtc) + .ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var refreshed = await calendarService.GetTasksForWeek(new CalendarTaskRequestModel + { + PropertyId = arp.PropertyId, + WeekStart = dayKey, + WeekEnd = dayKey, + BoardIds = [], + TagNames = [], + SiteIds = [], + ActionableOnly = true + }).ConfigureAwait(false); + + var refreshedTask = refreshed.Success && refreshed.Model != null + ? refreshed.Model.FirstOrDefault(t => t.Id == opgaveId) + : null; + + var opgave = refreshedTask != null + ? new Opgave + { + Id = refreshedTask.Id.ToString(CultureInfo.InvariantCulture), + EjendomId = refreshedTask.PropertyId.ToString(CultureInfo.InvariantCulture), + TavleId = refreshedTask.BoardId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + PlanDayKey = refreshedTask.TaskDate ?? string.Empty, + PlannedAt = string.Empty, + TaskText = refreshedTask.Title ?? string.Empty, + CalendarColor = refreshedTask.Color ?? string.Empty, + Completed = refreshedTask.Completed, + CompletedBy = string.Empty, + DescriptionHtml = refreshedTask.DescriptionHtml ?? string.Empty, + Comment = string.Empty, + ComplianceId = refreshedTask.ComplianceId ?? 0, + MicrotingSdkCaseId = refreshedTask.SdkCaseId ?? 0 + } + : new Opgave + { + // Compliance alive but calendar query didn't surface the row + // (e.g. ActionableOnly filtered it out). Treat the same as + // "no longer actionable" so the client drops it. + Id = opgaveId.ToString(CultureInfo.InvariantCulture), + EjendomId = arp.PropertyId.ToString(CultureInfo.InvariantCulture), + TavleId = string.Empty, + PlanDayKey = dayKey, + PlannedAt = string.Empty, + TaskText = string.Empty, + CalendarColor = string.Empty, + Completed = true, + CompletedBy = request.CompletedBy ?? string.Empty, + DescriptionHtml = string.Empty, + Comment = string.Empty + }; + + return new CompleteOpgaveResponse { Opgave = opgave }; + } + /// /// Stores the worker's comment for an opgave on the underlying SDK Case row. /// From e885cb6ab2ffb7f7b02b61fc7397301db5c340ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 10:43:33 +0200 Subject: [PATCH 10/22] fix(opgaver): emit per-case FieldValue, surface CaseUpdate failures The SDK's Field.FieldValue (singular) is the template DefaultValue and never reassigned from the per-case FieldValues[]. Mapping that to the wire meant every stream poll overwrote the user's typed value with the template default, producing the 'type -> reset to empty' loop on the mobile worker. Plugin's MapToFormField now reads from f.FieldValues[0].Value when populated, falling back to f.FieldValue (template default) only when no per-case row exists or its value is empty. Also: SetFieldValue handler now checks the bool return of Core.CaseUpdate and throws FailedPrecondition when false, instead of swallowing silent write failures and letting the client believe the save succeeded. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index cbaa5f7c1..24ddfc79b 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -531,7 +531,23 @@ private static FormField MapToFormField(SdkDataItem di) } else { - field.Value = f.FieldValue ?? string.Empty; + // f.FieldValue (singular) is the *template* DefaultValue (e.g. "False" + // for a CheckBox, "" for Comment) — DbFieldToField sets it once from + // the eForm definition and never reassigns it from per-case data. + // The actual user-entered answer lives in f.FieldValues[0].Value + // (populated by SqlController's CaseRead at lines 1715-1724 / 1785-1789 + // from the field_values table for this case). Without this, every + // stream poll overwrites the user's optimistic write with the template + // default, producing the "type → reset to empty" loop on the worker. + // + // Empty-string handling: IsNullOrEmpty treats both null and "" + // as "no per-case value" → fall back to template default. This means + // a user clearing a Comment to "" sees the default placeholder return + // (minor edge case; documented in fix commit). + var perCaseValue = f.FieldValues?.FirstOrDefault()?.Value; + field.Value = !string.IsNullOrEmpty(perCaseValue) + ? perCaseValue + : (f.FieldValue ?? string.Empty); } break; default: @@ -2170,9 +2186,28 @@ public override async Task SetFieldValue( try { - await core.CaseUpdate(caseId, fieldValueList, []).ConfigureAwait(false); + // Core.CaseUpdate wraps in try/catch and returns false on failure + // (only Log.LogFail side effect — see eform-sdk Core.cs:1665-1669). + // Without this check the silent write failure would look like + // success to the client; the next stream poll then overwrites the + // user's optimistic value with the template default, leaving the + // user confused. FailedPrecondition is treated by the Flutter side + // as a permanent error → conflict modal → user can retry. + var ok = await core.CaseUpdate(caseId, fieldValueList, []).ConfigureAwait(false); + if (!ok) + { + logger.LogError( + "OpgaverGrpcService.SetFieldValue: Core.CaseUpdate returned false for opgave {OpgaveId} field {FieldId} caseId {CaseId}", + opgaveId, request.FieldId, caseId); + throw new RpcException(new Status(StatusCode.FailedPrecondition, + "Field value persistence failed in SDK CaseUpdate")); + } await core.CaseUpdateFieldValues(caseId, language).ConfigureAwait(false); } + catch (RpcException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, From 47f206575f509aadf5ddca2ba643061c4df483cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 12:54:57 +0200 Subject: [PATCH 11/22] feat(opgaver): new ListTaskTracker RPC mirroring angular task-tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile worker's full opgaver list (røde opgaver included) needs the same scope as /plugins/backend-configuration-pn/task-tracker: property- scoped, no deadline window, missed and completed rotations included with per-row status. The existing GetTasksForWeek + ActionableOnly path stays untouched; this adds a sibling RPC + service method. Adds task_is_expired bool to Opgave (deadline passed and case retracted), leaves completed (Case.Status=100) as-is. ListTaskTracker request takes property_id; response is the same Opgave list shape as the calendar. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Calendar/CalendarTaskResponseModel.cs | 8 + .../Protos/opgaver.proto | 19 ++ .../BackendConfigurationCalendarService.cs | 227 ++++++++++++++++++ .../IBackendConfigurationCalendarService.cs | 21 ++ .../GrpcServices/OpgaverGrpcService.cs | 90 +++++++ 5 files changed, 365 insertions(+) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskResponseModel.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskResponseModel.cs index 751ae028e..d727588f8 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskResponseModel.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskResponseModel.cs @@ -36,4 +36,12 @@ public class CalendarTaskResponseModel public int? ItemPlanningTagId { get; set; } public string? DescriptionHtml { get; set; } public List Attachments { get; set; } = new(); + + /// + /// True when the underlying compliance/case is past deadline AND not + /// completed (or retracted with SDK Case Status=77). Populated by the + /// task-tracker service path; the calendar-week path leaves this as + /// false (it pre-filters non-actionable rows out). + /// + public bool TaskIsExpired { get; set; } } diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto index 03a00014d..9ec659f10 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto @@ -11,6 +11,7 @@ service Opgaver { rpc ListEjendomme(ListEjendommeRequest) returns (ListEjendommeResponse); rpc ListTavler(ListTavlerRequest) returns (ListTavlerResponse); rpc ListOpgaver(ListOpgaverRequest) returns (ListOpgaverResponse); + rpc ListTaskTracker(ListTaskTrackerRequest) returns (ListTaskTrackerResponse); rpc StreamOpgaveChanges(StreamOpgaveChangesRequest) returns (stream OpgaveChange); rpc CompleteOpgave(CompleteOpgaveRequest) returns (CompleteOpgaveResponse); rpc SetComment(SetCommentRequest) returns (SetCommentResponse); @@ -98,6 +99,16 @@ message ListOpgaverRequest { } message ListOpgaverResponse { repeated Opgave opgaver = 1; } +// Full property-scoped compliance list for the mobile worker's "task +// tracker" view (mirror of the angular admin's BackendConfigurationTaskTrackerHelper.Index). +// No deadline window — actionable + missed + completed rotations are all +// returned, with per-row status carried on the existing `completed` field +// and the new `task_is_expired` field (see Opgave below). +message ListTaskTrackerRequest { + int32 property_id = 1; +} +message ListTaskTrackerResponse { repeated Opgave opgaver = 1; } + message StreamOpgaveChangesRequest { string ejendom_id = 1; string tavle_id = 2; @@ -150,6 +161,14 @@ message Opgave { // Server-assigned PK of the SDK Case row backing this opgave. 0 means // "no backing case" (recurrence-only) or a legacy server. int64 microting_sdk_case_id = 17; + // True when the opgave's deadline has passed AND the underlying case is + // either retracted (Case.WorkflowState=Removed AND Status=77) or simply + // past its deadline without being completed (Case.Deadline < UtcNow AND + // Status != 100). Surfaced primarily by ListTaskTracker so flutter can + // render missed rotations in red. The calendar-week view (ListOpgaver) + // pre-filters non-actionable rows out and so emits this as false; default + // proto3 zero-value remains backwards-compatible with older clients. + bool task_is_expired = 18; } message Attachment { diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs index 563099306..d962c229f 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs @@ -2263,4 +2263,231 @@ public async Task DeleteFile(int taskId, int fileId) $"{localizationService.GetString("ErrorWhileDeletingAttachment")}: {e.Message}"); } } + + /// + /// + /// Implementation mirrors the compliance branch of + /// (lines 514-583) AND the angular + /// BackendConfigurationTaskTrackerHelper.Index path + /// (Infrastructure/Helpers/BackendConfigurationTaskTrackerHelper.cs:46-351), + /// but without a deadline window — every non-removed compliance under + /// the property is returned. The SDK Case is loaded in one batched IN + /// query so we can populate + /// (Case.Status == 100) and + /// + /// ((Case.WorkflowState=Removed AND Status=77) OR + /// (compliance.Deadline < UtcNow AND Status != 100)). + /// + public async Task>> GetTaskTrackerList( + int propertyId, int? sdkSiteIdForFilter) + { + try + { + var userLanguageId = (await userService.GetCurrentUserLanguage()).Id; + var dateTimeNow = DateTime.UtcNow; + var result = new List(); + + // Default board for missing-board fallback (parity with GetTasksForWeek line 53-59). + var defaultBoard = await backendConfigurationPnDbContext.CalendarBoards + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.PropertyId == propertyId) + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(); + var defaultBoardId = defaultBoard?.Id; + + // Full-scope compliance load — no deadline window, property scoped. + // Mirrors BackendConfigurationTaskTrackerHelper.cs:59-65 + 67-76. + // WorkflowState NULL is treated as "not removed" to match the + // ActionableOnly branch convention applied to other mobile-worker + // queries on this service. + var compliances = await backendConfigurationPnDbContext.Compliances + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) + .Where(x => x.PropertyId == propertyId) + .OrderBy(x => x.Deadline) + .ToListAsync(); + + if (compliances.Count == 0) + { + return new OperationDataResult>(true, result); + } + + // Batch-fetch the SDK Cases backing those compliances so we can + // derive Completed + TaskIsExpired without an N+1 round-trip. + var sdkCaseIds = compliances + .Select(c => c.MicrotingSdkCaseId) + .Where(id => id > 0) + .Distinct() + .ToList(); + + var sdkCore = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContextLocal = sdkCore.DbContextHelper.GetDbContext(); + var sdkCasesById = await sdkDbContextLocal.Cases + .Where(c => sdkCaseIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + + var planningIds = compliances.Select(x => x.PlanningId).Distinct().ToList(); + + // Batch-load AreaRulePlannings (mirrors GetTasksForWeek lines 480-488). + var complianceArps = await backendConfigurationPnDbContext.AreaRulePlannings + .Where(x => planningIds.Contains(x.ItemPlanningId)) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Include(x => x.AreaRule) + .ThenInclude(x => x.AreaRuleTranslations) + .Include(x => x.PlanningSites) + .Include(x => x.AreaRulePlanningFiles) + .ToListAsync(); + var complianceArpDict = complianceArps.ToDictionary(x => x.ItemPlanningId); + + var complianceArpIds = complianceArps.Select(x => x.Id).ToList(); + var complianceCalConfigs = await backendConfigurationPnDbContext.CalendarConfigurations + .Where(x => complianceArpIds.Contains(x.AreaRulePlanningId)) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .ToDictionaryAsync(x => x.AreaRulePlanningId); + + var complianceArpTags = await backendConfigurationPnDbContext.AreaRulePlanningTags + .Where(x => complianceArpIds.Contains(x.AreaRulePlanningId)) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .ToListAsync(); + + var complianceTagItemIds = complianceArpTags.Select(x => x.ItemPlanningTagId).Distinct().ToList(); + var compliancePlanningTagNames = await itemsPlanningPnDbContext.PlanningTags + .Where(x => complianceTagItemIds.Contains(x.Id)) + .ToDictionaryAsync(x => x.Id, x => x.Name); + + var compliancePlanningsDict = await itemsPlanningPnDbContext.Plannings + .Where(x => planningIds.Contains(x.Id)) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .ToDictionaryAsync(x => x.Id); + + // PlanningSite ↔ Site mapping for the per-row Worker filter. + // Parity with BackendConfigurationTaskTrackerHelper.cs:166-184. + var planningSiteIdsByPlanning = await itemsPlanningPnDbContext.PlanningSites + .Where(x => planningIds.Contains(x.PlanningId)) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .GroupBy(x => x.PlanningId) + .ToDictionaryAsync(g => g.Key, g => g.Select(p => p.SiteId).Distinct().ToList()); + + foreach (var compliance in compliances) + { + complianceArpDict.TryGetValue(compliance.PlanningId, out var arp); + CalendarConfiguration calConfig = null; + if (arp != null) + complianceCalConfigs.TryGetValue(arp.Id, out calConfig); + + if (!compliancePlanningsDict.TryGetValue(compliance.PlanningId, out var planning)) + { + // Mirrors TaskTrackerHelper.cs:142-145 — orphan compliance, skip. + continue; + } + + // Per-row Worker filter (parity with TaskTrackerHelper.cs:178-192, + // collapsed to a single sdk-site check because the mobile worker + // call passes exactly one site id; null disables the filter for + // admin-style callers). + if (sdkSiteIdForFilter.HasValue) + { + if (!planningSiteIdsByPlanning.TryGetValue(compliance.PlanningId, out var planningSiteIds) + || !planningSiteIds.Contains(sdkSiteIdForFilter.Value)) + { + continue; + } + } + + var title = compliance.ItemName ?? ""; + if (arp?.AreaRule?.AreaRuleTranslations != null) + { + title = arp.AreaRule.AreaRuleTranslations + .Where(t => t.LanguageId == userLanguageId) + .Select(t => t.Name) + .FirstOrDefault() ?? title; + } + + var tags = arp != null + ? complianceArpTags + .Where(x => x.AreaRulePlanningId == arp.Id) + .Select(x => compliancePlanningTagNames.TryGetValue(x.ItemPlanningTagId, out var name) ? name : null) + .Where(x => x != null) + .ToList() + : []; + + var compIsRepeatAlways = arp?.RepeatType.HasValue == true && arp.RepeatType.Value == 1 && (arp.RepeatEvery ?? 0) == 0; + var compHasNonAlwaysRepeat = arp?.RepeatType.HasValue == true && arp.RepeatType.Value > 0 && !compIsRepeatAlways; + var compIsAllDay = calConfig == null && !compHasNonAlwaysRepeat; + + // Per-row Completed + TaskIsExpired derivation. Predicate + // matches the spec: completed = Case.Status==100; + // task_is_expired = (Case.WorkflowState=Removed AND + // Status=77) OR (compliance.Deadline < UtcNow AND + // Status != 100). Recurrence-only or missing-Case rows fall + // back to the deadline-only check (no Status to consult, so + // they are treated as not-completed). + bool completed = false; + bool taskIsExpired; + if (compliance.MicrotingSdkCaseId > 0 + && sdkCasesById.TryGetValue(compliance.MicrotingSdkCaseId, out var sdkCase) + && sdkCase != null) + { + completed = sdkCase.Status == 100; + var retracted = sdkCase.WorkflowState == Constants.WorkflowStates.Removed + && sdkCase.Status == 77; + var pastDueIncomplete = compliance.Deadline < dateTimeNow + && sdkCase.Status != 100; + taskIsExpired = retracted || pastDueIncomplete; + } + else + { + taskIsExpired = compliance.Deadline < dateTimeNow; + } + + var model = new CalendarTaskResponseModel + { + Id = arp?.Id ?? 0, + Title = title, + StartHour = compIsAllDay ? 0 : calConfig?.StartHour ?? 9.0, + Duration = compIsAllDay ? 0 : calConfig?.Duration ?? 1.0, + TaskDate = compliance.Deadline.ToString("yyyy-MM-dd"), + Tags = tags, + AssigneeIds = arp?.PlanningSites? + .Where(ps => ps.WorkflowState != Constants.WorkflowStates.Removed) + .Select(ps => (int)ps.SiteId) + .ToList() ?? [], + BoardId = calConfig?.BoardId ?? defaultBoardId, + Color = calConfig?.Color, + RepeatType = arp?.RepeatType ?? 0, + RepeatEvery = arp?.RepeatEvery ?? 1, + RepeatEndMode = arp?.RepeatEndMode, + RepeatOccurrences = arp?.RepeatOccurrences, + RepeatUntilDate = arp?.RepeatUntilDate, + DayOfWeek = arp?.DayOfWeek, + DayOfMonth = arp?.DayOfMonth, + RepeatWeekdaysCsv = arp?.RepeatWeekdaysCsv, + Completed = completed, + PropertyId = compliance.PropertyId, + ComplianceId = compliance.Id, + IsFromCompliance = true, + Deadline = compliance.Deadline, + NextExecutionTime = planning.NextExecutionTime, + PlanningId = compliance.PlanningId, + IsAllDay = compIsAllDay, + EformId = arp?.AreaRule?.EformId, + SdkCaseId = compliance.MicrotingSdkCaseId, + ItemPlanningTagId = arp?.ItemPlanningTagId, + DescriptionHtml = planning.Description, + Attachments = MapAttachments(arp), + TaskIsExpired = taskIsExpired + }; + + result.Add(model); + } + + return new OperationDataResult>(true, result); + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + logger.LogError(e, "BackendConfigurationCalendarService.GetTaskTrackerList: {Message}", e.Message); + return new OperationDataResult>(false, + $"{localizationService.GetString("ErrorWhileGettingCalendarTasks")}: {e.Message}"); + } + } } diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/IBackendConfigurationCalendarService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/IBackendConfigurationCalendarService.cs index 93a4fab5b..dead0f0ba 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/IBackendConfigurationCalendarService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/IBackendConfigurationCalendarService.cs @@ -9,6 +9,27 @@ namespace BackendConfiguration.Pn.Services.BackendConfigurationCalendarService; public interface IBackendConfigurationCalendarService { Task>> GetTasksForWeek(CalendarTaskRequestModel requestModel); + + /// + /// Returns the FULL property-scoped compliance list (no deadline window): + /// actionable + missed + completed rotations, each annotated with + /// (Case.Status=100) + /// and (deadline + /// passed AND case retracted or not yet completed). + /// + /// Mirror of BackendConfigurationTaskTrackerHelper.Index + /// (Infrastructure/Helpers/BackendConfigurationTaskTrackerHelper.cs:46-351). + /// Sibling to — does NOT modify the + /// calendar-week query path. + /// + /// When is non-null, only + /// compliances whose planning sites include that site are returned — + /// parity with the angular per-row Worker filter + /// (BackendConfigurationTaskTrackerHelper.cs:178-192). Pass null to + /// disable site filtering (admin context). + /// + Task>> GetTaskTrackerList( + int propertyId, int? sdkSiteIdForFilter); Task CreateTask(CalendarTaskCreateRequestModel createModel); Task UpdateTask(CalendarTaskUpdateRequestModel updateModel); Task DeleteTask(CalendarTaskDeleteRequestModel deleteModel); diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 24ddfc79b..9a85fc0af 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -264,6 +264,96 @@ public override async Task ListOpgaver( return response; } + /// + /// Full property-scoped opgaver list for the mobile worker's "task + /// tracker" view. Mirror of the angular admin's + /// BackendConfigurationTaskTrackerHelper.Index (no deadline + /// window — actionable + missed + completed rotations all returned), + /// scoped to the calling worker's site via the same per-row Worker + /// filter that the angular path applies (TaskTrackerHelper.cs:178-192, + /// collapsed to a single sdk-site check on this RPC since the mobile + /// worker passes exactly one site). + /// + /// Permission gate is identical to : the + /// caller must hold a PropertyWorker access entry for + /// request.PropertyId on the resolved sdk site. Per-row Worker + /// filtering then narrows the result set to opgaver whose planning + /// sites include the same sdk site (so a worker who has access to a + /// property still only sees opgaver that target their site). + /// + public override async Task ListTaskTracker( + ListTaskTrackerRequest request, + ServerCallContext context) + { + var propertyId = request.PropertyId; + + var sdkSiteId = await siteResolver.GetSdkSiteIdAsync().ConfigureAwait(false); + if (!await userPropertyAccess.HasAccessAsync(sdkSiteId, propertyId) + .ConfigureAwait(false)) + { + throw new RpcException(new Status(StatusCode.PermissionDenied, + "Caller has no PropertyWorker access to the requested property.")); + } + + var result = await calendarService.GetTaskTrackerList(propertyId, (int)sdkSiteId) + .ConfigureAwait(false); + + var response = new ListTaskTrackerResponse(); + if (!result.Success || result.Model == null) + { + return response; + } + + // Reuse the same Case.Custom envelope + eForm field-structure + // helpers as ListOpgaver so writes (comments, photos, field values) + // round-trip identically across both views. + var envelopeByTaskId = await LoadEnvelopeByTaskIdAsync(result.Model) + .ConfigureAwait(false); + var fieldsByTaskId = await LoadFieldsByTaskIdAsync(result.Model) + .ConfigureAwait(false); + + foreach (var task in result.Model) + { + envelopeByTaskId.TryGetValue(task.Id, out var envelope); + var comment = envelope?.OpgaverComment?.Text ?? string.Empty; + + var opgave = new Opgave + { + Id = task.Id.ToString(CultureInfo.InvariantCulture), + EjendomId = task.PropertyId.ToString(CultureInfo.InvariantCulture), + TavleId = task.BoardId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + // plan_day_key is reused for the compliance deadline (yyyy-MM-dd) + // so the existing flutter Drift composite PK (id, planDayKey) + // remains stable per-rotation: a planning whose deadline rolls + // forward generates a new (id, planDayKey) pair rather than + // mutating an old row in place. This matches the calendar + // path, which also fills plan_day_key with the row's date. + PlanDayKey = task.TaskDate ?? string.Empty, + PlannedAt = string.Empty, + TaskText = task.Title ?? string.Empty, + CalendarColor = task.Color ?? string.Empty, + Completed = task.Completed, + CompletedBy = string.Empty, + DescriptionHtml = task.DescriptionHtml ?? string.Empty, + Comment = comment, + EformId = task.EformId ?? 0, + ComplianceId = task.ComplianceId ?? 0, + MicrotingSdkCaseId = task.SdkCaseId ?? 0, + TaskIsExpired = task.TaskIsExpired + }; + + PopulateAttachments(opgave, envelope); + if (fieldsByTaskId.TryGetValue(task.Id, out var fields)) + { + opgave.Fields.AddRange(fields); + } + + response.Opgaver.Add(opgave); + } + + return response; + } + /// /// Translates the opgaver_photos entries from the Case.Custom /// envelope into wire messages on the response From e1779734671862be9a71c9b2a5f5ef1df9429738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 13:49:30 +0200 Subject: [PATCH 12/22] fix(opgaver): mirror angular case-closure in CompleteOpgave The mobile CompleteOpgave handler updated PlanningCase + PlanningCaseSite + soft-deleted Compliance, but didn't touch the SDK Case row. Completing a missed-deadline rotation (Case.WorkflowState='removed' Status=77) left the case retracted, breaking parity with the angular admin's compliance/ case Save flow which revives the case via WorkflowState='Created' + Status=100 + DoneAt=now. Now sets Case.DoneAtUserModifiable + DoneAt + SiteId + Status=100 + WorkflowState='Created' before the PlanningCase/PlanningCaseSite updates, matching BackendConfigurationCompliancesService.Update lines 234-260. Also invokes CaseUpdateDelegate to broadcast the closure event. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 9a85fc0af..0dfc4936e 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -20,6 +20,7 @@ using Microting.eForm.Infrastructure.Constants; using Microting.eForm.Infrastructure.Models; using Microting.eFormApi.BasePn.Abstractions; +using Microting.eFormApi.BasePn.Infrastructure.Delegates.CaseUpdate; using Microting.EformBackendConfigurationBase.Infrastructure.Data; using Microting.EformBackendConfigurationBase.Infrastructure.Data.Entities; using Microting.ItemsPlanningBase.Infrastructure.Data; @@ -69,10 +70,17 @@ namespace BackendConfiguration.Pn.Services.GrpcServices; /// BackendConfigurationCompliancesService.Update JSON path. /// CompleteOpgave NOW performs (added in this PR): /// +/// SDK Case row update (DoneAt, DoneAtUserModifiable, +/// SiteId, Status=100, WorkflowState=Created — the latter REVIVES a +/// missed-deadline case whose WorkflowState was 'removed' so the admin +/// "filled cases" view can pick it up) — mirrors +/// BackendConfigurationCompliancesService.cs:234-260. +/// CaseUpdateDelegate broadcast — mirrors lines +/// 262-270; downstream subscribers (e.g. follow-on automation) get +/// notified the same way as on the angular admin path. /// PlanningCaseSite row update (Status=100, /// MicrotingSdkCaseId, MicrotingSdkCaseDoneAt, DoneByUserId, -/// DoneByUserName) — mirrors -/// BackendConfigurationCompliancesService.cs:307-318. +/// DoneByUserName) — mirrors lines 307-318. /// PlanningCase row update (Status=100, /// WorkflowState=Processed, MicrotingSdkCaseDoneAt, DoneByUserId, /// DoneByUserName) — mirrors lines 320-335. @@ -84,9 +92,6 @@ namespace BackendConfiguration.Pn.Services.GrpcServices; /// ComplianceStatusThirty recomputation — lines 344-371. Without /// this, the property compliance "dot" UI elsewhere in the system will be /// stale. -/// CaseUpdateDelegate invocation — lines 262-270 of -/// BackendConfigurationCompliancesService.Update. Downstream -/// subscribers won't be notified. /// core.CaseDelete of the underlying microting /// case — lines 373-389. The device-side case won't be deleted. /// @@ -1138,9 +1143,34 @@ public override async Task CompleteOpgave( foundCase.DoneAt = dayDoneAt; foundCase.SiteId = sdkSiteId; foundCase.Status = 100; + // Direct WorkflowState assignment (not entity.Delete) is the + // legitimate REVIVAL operation, mirroring + // BackendConfigurationCompliancesService.Update line 259. A + // missed-deadline rotation arrives here as Case.WorkflowState + // ='removed' Status=77 — completing it un-retracts the case so + // the admin "filled cases" view can pick it up. The standing + // "no hard-deletes / use entity.Delete()" rule is about deletion; + // this is the inverse (un-soft-delete) and is the only place in + // this service that writes WorkflowState directly. foundCase.WorkflowState = Constants.WorkflowStates.Created; await foundCase.Update(sdkDbContext).ConfigureAwait(false); + // Broadcast the case update to any registered subscribers. Mirrors + // BackendConfigurationCompliancesService.Update lines 262-270 — same + // delegate, same invocation pattern. CaseUpdateDelegates is a + // static registry in Microting.eFormApi.BasePn so no DI wiring is + // required; if no subscribers are registered the delegate is null + // and we skip. + if (CaseUpdateDelegates.CaseUpdateDelegate != null) + { + var invocationList = CaseUpdateDelegates.CaseUpdateDelegate + .GetInvocationList(); + foreach (var func in invocationList) + { + func.DynamicInvoke(foundCase.Id); + } + } + // Mirror the post-update sequence from // BackendConfigurationCompliancesService.Update (lines 307-335): // set Status=100 on PlanningCaseSite + parent PlanningCase so the From db7dc6c79083a31fc59f5cc0cca9b3f19239a668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 16:25:48 +0200 Subject: [PATCH 13/22] fix(opgaver): mirror angular case soft-delete after CompleteOpgave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity harness s2/s3/s5 caught: angular ends with Case.WorkflowState= 'removed' while mobile left it 'created'. The angular Update flow soft-deletes the SDK Case after the inline closure (location: BackendConfigurationCompliancesService.cs:373-389 → core.CaseDelete → SqlController.CaseDelete:1069 → aCase.Delete(db)). CompleteOpgave now mirrors that step via core.CaseDelete on foundCase.MicrotingUid (with the same checkListSite fallback the angular flow uses), which writes the WorkflowState='Removed' transition + CaseVersion snapshot. Same try/catch shape as the canonical path so a transient XML-server rejection logs but doesn't fail the whole RPC. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 0dfc4936e..401bee079 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -84,6 +84,11 @@ namespace BackendConfiguration.Pn.Services.GrpcServices; /// PlanningCase row update (Status=100, /// WorkflowState=Processed, MicrotingSdkCaseDoneAt, DoneByUserId, /// DoneByUserName) — mirrors lines 320-335. +/// core.CaseDelete of the underlying microting +/// case — mirrors lines 373-389. This soft-deletes the SDK Case +/// (WorkflowState='Removed') and writes a CaseVersions +/// snapshot row, matching the parity-harness's observed angular end +/// state. Required for s2/s3/s5 parity. /// /// Still omitted (deferred; closing the full gap requires factoring a shared /// completion helper — out of scope for this PR): @@ -92,8 +97,6 @@ namespace BackendConfiguration.Pn.Services.GrpcServices; /// ComplianceStatusThirty recomputation — lines 344-371. Without /// this, the property compliance "dot" UI elsewhere in the system will be /// stale. -/// core.CaseDelete of the underlying microting -/// case — lines 373-389. The device-side case won't be deleted. /// /// public class OpgaverGrpcService( @@ -1214,6 +1217,52 @@ public override async Task CompleteOpgave( planningCaseSite.PlanningCaseId = planningCase.Id; await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false); } + + // Mirror BackendConfigurationCompliancesService.Update lines 373-389 + // (canonical save path). After the SDK Case is updated to + // Status=100 / WorkflowState=Created, the angular flow finishes by + // calling core.CaseDelete on the case's MicrotingUid. Internally + // (Core.cs:1748 → SqlController.CaseDelete:1069) that deletes the + // case on the Microting server AND soft-deletes the local SDK Case + // row via aCase.Delete(db) — which sets + // WorkflowState='Removed' and writes a CaseVersions snapshot row. + // + // Without this step the parity harness reports two divergences on + // s2/s3/s5: SDK.Cases.WorkflowState='created' (vs angular's + // 'removed') and a missing CaseVersions row. Both are healed by + // mirroring the same call here. + // + // Wrapped in the same try/catch shape as the angular code so a + // server-side failure (e.g. transient XML rejection) is logged and + // doesn't fail the whole CompleteOpgave RPC. We use the + // Core.CaseDelete helper (NOT a manual aCase.Delete) — same call, + // same side effects, same retry-on-"Parsing in progress" handling. + try + { + if (foundCase.MicrotingUid != null) + { + await core.CaseDelete((int)foundCase.MicrotingUid).ConfigureAwait(false); + } + else + { + var checkListSite = await sdkDbContext.CheckListSites + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == foundCase.Id) + .ConfigureAwait(false); + if (checkListSite != null) + { + await core.CaseDelete(checkListSite.MicrotingUid).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, + "core.CaseDelete failed for caseId={CaseId} microtingUid={MicrotingUid} — " + + "case completion otherwise succeeded; the row will linger as " + + "WorkflowState=Created on the SDK side until reconciliation.", + foundCase.Id, foundCase.MicrotingUid); + } } // Re-read the calendar tasks for the day in question so we can return From 93b11848bdc7976f406c2549509177e0bb1ce06a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 16:49:51 +0200 Subject: [PATCH 14/22] fix(opgaver): canonicalize CheckBox + select values in SetFieldValue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity harness caught two related encoding mismatches: 1. CheckBox: angular admin's CaseUpdateHelper normalizes incoming values to "checked"/"unchecked"; the mobile gRPC handler skipped that step. 2. SingleSelect/MultiSelect: angular stores FieldOption.Key (e.g. "1"), but flutter clients render the localized FieldOptionTranslation.Text (e.g. "Ja"); without server-side translation the keys never match the SDK's option lookup at SqlController.cs:3787 and the option fails to resolve through CaseUpdateFieldValues. OpgaverGrpcService.SetFieldValue now reads the SDK Field's FieldType and canonicalizes the incoming value before constructing the SDK pipe-pair: CheckBox case-insensitively maps "1"/"true"/"checked"/"yes"/"ja" → "checked" and the inverses → "unchecked"; SingleSelect/MultiSelect resolve labels back to FieldOption.Key via FieldOptionTranslations (language-preferred, with any-language fallback). Other field types pass through unchanged so existing callers are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 192 +++++++++++++++++- 1 file changed, 191 insertions(+), 1 deletion(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 401bee079..af6fd41a2 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -2348,9 +2348,35 @@ public override async Task SetFieldValue( $"No FieldValue row exists for case {caseId} field {request.FieldId}.")); } + // Canonicalize the incoming value to match the storage form the angular + // admin path produces (CaseUpdateHelper.GetFieldValuesByRequestField in + // eFormApi.BasePn/Infrastructure/Helpers/CaseUpdateHelper.cs:63-186). + // The angular wire format and the SDK lookups (SqlController.cs:1303-1310, + // 2249-2261, 3787, 3812) assume: + // * CheckBox → "checked" / "unchecked" + // * SingleSelect / MultiSelect → FieldOption.Key (numeric option id), + // not the localized translation text + // The flutter-side gRPC client may send any of: a true/false flag, the + // localized label ("Ja", "Nej"), or the canonical key ("1"). We + // normalize here so the device UI does not need to know the storage + // convention. Other field types pass through unchanged. + var fieldTypeName = await sdkDbContext.Fields + .Where(f => f.Id == request.FieldId) + .Join(sdkDbContext.FieldTypes, + f => f.FieldTypeId, + ft => ft.Id, + (f, ft) => ft.Type) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + + var rawValue = request.Value ?? string.Empty; + var canonicalValue = await CanonicalizeFieldValueAsync( + sdkDbContext, request.FieldId, fieldTypeName, rawValue, language.Id) + .ConfigureAwait(false); + var fieldValueList = new List { - $"{fieldValueRowId}|{request.Value ?? string.Empty}" + $"{fieldValueRowId}|{canonicalValue}" }; try @@ -2496,4 +2522,168 @@ private static System.Collections.Generic.List TryParseBoardIds(string raw) // Non-numeric tavle_id is treated as "no board filter" rather than a hard failure. return []; } + + /// + /// Normalize an incoming SetFieldValue value into the canonical wire form + /// the angular admin path produces. The reference implementation is + /// CaseUpdateHelper.GetFieldValuesByRequestField + /// (eFormApi.BasePn/Infrastructure/Helpers/CaseUpdateHelper.cs:63-186); the + /// SDK lookups expect: + /// * CheckBox → "checked" / "unchecked" (SqlController.cs:1303-1310, + /// 2249-2261). Anything truthy ("1", "true", "checked", "yes", "ja") → + /// "checked"; anything falsy or empty → "unchecked". + /// * SingleSelect / MultiSelect → FieldOption.Key (numeric + /// option id), not the localized translation text. SDK matches on + /// FieldOption.Key == FieldValue.Value at SqlController.cs:3787 + /// and 3812. If the caller sent a label instead of a key, we resolve + /// it via FieldOptionTranslations against the language (any language + /// match counts — labels are unique within a field), and fall back to + /// the raw value if no match is found. + /// All other field types pass through unchanged. + /// + private static async Task CanonicalizeFieldValueAsync( + Microting.eForm.Infrastructure.MicrotingDbContext sdkDbContext, + int fieldId, + string? fieldTypeName, + string rawValue, + int languageId) + { + if (string.IsNullOrEmpty(fieldTypeName)) + { + return rawValue; + } + + switch (fieldTypeName) + { + case Constants.FieldTypes.CheckBox: + { + var v = rawValue.Trim(); + if (v.Length == 0) + { + return "unchecked"; + } + var lower = v.ToLowerInvariant(); + if (lower is "1" or "true" or "checked" or "yes" or "ja") + { + return "checked"; + } + if (lower is "0" or "false" or "unchecked" or "no" or "nej") + { + return "unchecked"; + } + // Unknown literal — pass through; the SDK's default branch + // (SqlController.cs:1309) preserves whatever was stored. + return v; + } + + case Constants.FieldTypes.SingleSelect: + { + if (string.IsNullOrEmpty(rawValue)) + { + return rawValue; + } + return await ResolveFieldOptionKeyAsync( + sdkDbContext, fieldId, rawValue, languageId).ConfigureAwait(false); + } + + case Constants.FieldTypes.MultiSelect: + { + if (string.IsNullOrEmpty(rawValue)) + { + return rawValue; + } + // MultiSelect wire format is pipe-joined keys (SqlController.cs:3804). + // Resolve each segment independently so callers may send either keys, + // labels, or any mix. + var segments = rawValue.Split('|'); + var resolved = new List(segments.Length); + foreach (var seg in segments) + { + if (string.IsNullOrEmpty(seg)) + { + resolved.Add(seg); + continue; + } + resolved.Add(await ResolveFieldOptionKeyAsync( + sdkDbContext, fieldId, seg, languageId).ConfigureAwait(false)); + } + return string.Join("|", resolved); + } + + default: + return rawValue; + } + } + + /// + /// Map a single SingleSelect/MultiSelect input value back to the + /// canonical FieldOption.Key. Order of resolution: + /// 1. If exactly matches an existing + /// FieldOption.Key for this field, return it as-is (caller already + /// sent the canonical form). + /// 2. Otherwise, look up FieldOptionTranslations by Text == rawValue + /// for this field. Match on the requested language first; if no + /// hit, fall back to any language (translations of the same option + /// across languages are mutually exclusive at the Key level). + /// 3. If no translation matches, return the raw value unchanged so the + /// SDK's existing not-found behaviour (newValue stays empty) is + /// preserved — diagnosing the failure shifts to the + /// CaseUpdateFieldValues read path rather than corrupting writes. + /// + private static async Task ResolveFieldOptionKeyAsync( + Microting.eForm.Infrastructure.MicrotingDbContext sdkDbContext, + int fieldId, + string rawValue, + int languageId) + { + // Step 1 — caller already sent a key. + var keyMatch = await sdkDbContext.FieldOptions + .Where(fo => fo.FieldId == fieldId + && fo.WorkflowState != Microting.eForm.Infrastructure.Constants.Constants.WorkflowStates.Removed + && fo.Key == rawValue) + .Select(fo => fo.Key) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + if (!string.IsNullOrEmpty(keyMatch)) + { + return keyMatch; + } + + // Step 2a — translate label → key, prefer the requested language. + var preferred = await ( + from fo in sdkDbContext.FieldOptions + join fot in sdkDbContext.FieldOptionTranslations + on fo.Id equals fot.FieldOptionId + where fo.FieldId == fieldId + && fo.WorkflowState != + Microting.eForm.Infrastructure.Constants.Constants.WorkflowStates.Removed + && fot.LanguageId == languageId + && fot.Text == rawValue + select fo.Key + ).FirstOrDefaultAsync().ConfigureAwait(false); + if (!string.IsNullOrEmpty(preferred)) + { + return preferred; + } + + // Step 2b — fall back to any language (handles flutter clients that + // request a different locale than the worker's primary). + var anyLang = await ( + from fo in sdkDbContext.FieldOptions + join fot in sdkDbContext.FieldOptionTranslations + on fo.Id equals fot.FieldOptionId + where fo.FieldId == fieldId + && fo.WorkflowState != + Microting.eForm.Infrastructure.Constants.Constants.WorkflowStates.Removed + && fot.Text == rawValue + select fo.Key + ).FirstOrDefaultAsync().ConfigureAwait(false); + if (!string.IsNullOrEmpty(anyLang)) + { + return anyLang; + } + + // Step 3 — no match; pass through. + return rawValue; + } } From ddd909daee208ebf142bbae04ac8134374a4ffca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 16:59:14 +0200 Subject: [PATCH 15/22] fix(opgaver): write empty-string to NULL FieldValues on CompleteOpgave Parity harness s3 caught: angular's Save flow writes "" to every NULL FieldValue (and emits a FieldValueVersion per write), because its CaseEditRequest carries the full ReplyElement tree including unchanged fields. Mobile's CompleteOpgave only touched fields the user had previously SetFieldValue'd, leaving NULLs as NULL. Now reads all NULL FieldValues for the case being completed and writes "" to them in a single Core.CaseUpdate batch, mirroring angular's batch-save semantics. Non-NULL values are not touched (no clobber of existing data; no duplicate version rows for already-empty values). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index af6fd41a2..8e5c66a7e 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -1218,6 +1218,38 @@ public override async Task CompleteOpgave( await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false); } + // Parity harness s3 fix: angular's /api/.../compliances/cases PUT + // ships the full ReplyElement tree from CaseEditRequest, so its + // CaseUpdateHelper batch-saves a "[fieldValueId]|" pair for every + // field — including unchanged ones still sitting at Value=NULL. + // PnBase rewrites NULL → "" and emits one FieldValueVersion row per + // write. Mobile's CompleteOpgave never touches FieldValues unless a + // SetFieldValue preceded it, so NULLs survive and no version rows + // emit. We mirror angular's batch by writing "" into every NULL + // FieldValue for this case in a single Core.CaseUpdate call. We do + // NOT touch non-NULL rows (would clobber user edits) or rows that + // are already "" (angular only versions on actual transitions). + var nullFieldValues = await sdkDbContext.FieldValues + .Where(fv => fv.CaseId == foundCase.Id + && fv.Value == null + && (fv.WorkflowState == null + || fv.WorkflowState != Constants.WorkflowStates.Removed)) + .ToListAsync() + .ConfigureAwait(false); + if (nullFieldValues.Count > 0) + { + var emptyPairs = nullFieldValues + .Select(fv => $"{fv.Id}|") + .ToList(); + var languageForBatch = await sdkDbContext.Languages + .FirstAsync() + .ConfigureAwait(false); + await core.CaseUpdate(caseId, emptyPairs, []) + .ConfigureAwait(false); + await core.CaseUpdateFieldValues(caseId, languageForBatch) + .ConfigureAwait(false); + } + // Mirror BackendConfigurationCompliancesService.Update lines 373-389 // (canonical save path). After the SDK Case is updated to // Status=100 / WorkflowState=Created, the angular flow finishes by From 45b0dc45a0b5629b05245f46642d7eb6bddc2d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 17:03:20 +0200 Subject: [PATCH 16/22] Revert "fix(opgaver): write empty-string to NULL FieldValues on CompleteOpgave" This reverts commit ddd909daee208ebf142bbae04ac8134374a4ffca. --- .../GrpcServices/OpgaverGrpcService.cs | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 8e5c66a7e..af6fd41a2 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -1218,38 +1218,6 @@ public override async Task CompleteOpgave( await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false); } - // Parity harness s3 fix: angular's /api/.../compliances/cases PUT - // ships the full ReplyElement tree from CaseEditRequest, so its - // CaseUpdateHelper batch-saves a "[fieldValueId]|" pair for every - // field — including unchanged ones still sitting at Value=NULL. - // PnBase rewrites NULL → "" and emits one FieldValueVersion row per - // write. Mobile's CompleteOpgave never touches FieldValues unless a - // SetFieldValue preceded it, so NULLs survive and no version rows - // emit. We mirror angular's batch by writing "" into every NULL - // FieldValue for this case in a single Core.CaseUpdate call. We do - // NOT touch non-NULL rows (would clobber user edits) or rows that - // are already "" (angular only versions on actual transitions). - var nullFieldValues = await sdkDbContext.FieldValues - .Where(fv => fv.CaseId == foundCase.Id - && fv.Value == null - && (fv.WorkflowState == null - || fv.WorkflowState != Constants.WorkflowStates.Removed)) - .ToListAsync() - .ConfigureAwait(false); - if (nullFieldValues.Count > 0) - { - var emptyPairs = nullFieldValues - .Select(fv => $"{fv.Id}|") - .ToList(); - var languageForBatch = await sdkDbContext.Languages - .FirstAsync() - .ConfigureAwait(false); - await core.CaseUpdate(caseId, emptyPairs, []) - .ConfigureAwait(false); - await core.CaseUpdateFieldValues(caseId, languageForBatch) - .ConfigureAwait(false); - } - // Mirror BackendConfigurationCompliancesService.Update lines 373-389 // (canonical save path). After the SDK Case is updated to // Status=100 / WorkflowState=Created, the angular flow finishes by From 98c782e730eb0ceb05cc9dbfa0d7443a2e6547b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 17:41:18 +0200 Subject: [PATCH 17/22] fix(opgaver): mirror angular GET-case FieldValue selector on Complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The angular GET /api/backend-configuration-pn/compliances/cases?id&templateId returns a ReplyElement built by SqlController.CheckRead, which materialises every Field's FieldValue and runs them through ReadFieldValue. For Number and NumberStepper fields, ReadFieldValue rewrites NULL → "" before JSON serialisation (eform-sdk SqlController.cs lines 2217-2231) — Date does the same (lines 2233-2247) but the round-trip parser rejects "" so it never emits a write pair. Other types keep NULL on the wire. Angular's PUT then runs CaseUpdateHelper.GetFieldValuesByRequestField (eFormApi.BasePn CaseUpdateHelper.cs lines 95-103). It emits a "[fieldValueId]|" pair for every Number whose Value is non-null on the wire — which after the GET-case rewrite includes every NULL Number FieldValue. Core.CaseUpdate (Core.cs lines 1649-1654) then writes "" to those FieldValues and PnBase.Update emits a Version row. Mobile's CompleteOpgave was skipping FieldValues entirely on empty-complete, so the parity-harness s3 scenario consistently showed: - 420_SDK.FieldValueVersions: row only in ANGULAR - 420_SDK.FieldValues pk=N: Value angular="" mobile=null The previous commit (reverted in 45b0dc45) tried to fix this by writing "" to ALL NULL FieldValues for the case, which over-fired and clobbered non-Number FVs that angular deliberately leaves untouched (regressing s2/s5 in the harness). This change mirrors the EXACT canonical filter: - Field is in the case's CheckList tree (Field.WorkflowState != Removed, Field.CheckListId IN (case CheckList ∪ subtree CheckLists)) - FieldValue.CaseId == foundCase.Id - FieldValue.Value IS NULL - FieldValue.WorkflowState != Removed - Field's FieldType is Number or NumberStepper Verified by parity-harness s2 / s3 / s5: all three scenarios produce byte-identical DB deltas across angular and mobile. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index af6fd41a2..9398ba31f 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -1142,6 +1142,102 @@ public override async Task CompleteOpgave( if (foundCase != null) { + // Parity harness s3 (empty-complete) fix: mirror the angular + // GET-then-PUT round-trip's writeback for NULL Number / + // NumberStepper FieldValues. The chain is: + // + // 1) BackendConfigurationCompliancesService.Read(id) calls + // Core.CaseRead → SqlController.CheckRead, which builds the + // ReplyElement by walking the case's CheckList tree and + // materialising every Field's FieldValue + // (eform-sdk/eFormCore/Infrastructure/SqlController.cs lines + // 1605-1796: Where(WorkflowState != Removed) ParentFieldId + // gating, plus per-FieldValue ReadFieldValue). + // 2) ReadFieldValue serialises the FieldValue.Value verbatim + // EXCEPT for Number / NumberStepper / Date / a few others — + // those rewrite NULL → "" before JSON serialisation + // (SqlController.cs lines 2217-2231 for Number / NumberStepper, + // 2233-2247 for Date). + // 3) The harness round-trips the ReplyElement unchanged into + // CaseEditRequest[]; angular's + // CaseUpdateHelper.GetFieldValuesByRequestField + // (eFormApi.BasePn/.../CaseUpdateHelper.cs lines 95-103) emits + // a "[fieldValueId]|" pair for any Number whose Value is + // non-null on the wire — which after step 2 includes every + // NULL Number FieldValue. Date's TryParseExact("",...) fails + // so Date does NOT emit a pair (lines 113-137). + // 4) BackendConfigurationCompliancesService.Update line 223 calls + // Core.CaseUpdate, which calls SqlController.FieldValueUpdate + // (Core.cs lines 1649-1654) — that writes Value="" to the + // matching FieldValue and PnBase.Update emits a Version row. + // + // Net: only Number / NumberStepper FieldValues whose Value is + // NULL get rewritten to "". Mobile's CompleteOpgave previously + // skipped FieldValues entirely on empty-complete, so the angular + // delta included a FieldValueVersion row plus an updated + // FieldValue.Value="" that mobile didn't emit. + // + // The filter below is the EXACT canonical set: Field is in the + // case's CheckList tree (Field.WorkflowState != Removed, + // Field.CheckListId IN (case CheckList ∪ subtree CheckLists)), + // FieldValue.CaseId == foundCase.Id, FieldValue.Value IS NULL, + // FieldValue.WorkflowState != Removed, AND Field's FieldType is + // Number or NumberStepper. Other types either keep NULL on the + // wire (filtered out by GetFieldValuesByRequestField) or + // round-trip through a parser that rejects "" (Date). + // + // Why scoped to the CheckList tree (not all FVs for the case): + // SqlController.CheckRead only walks Fields whose CheckListId is + // in the case's CheckList ∪ subtree (lines 1605-1668), so a + // FieldValue whose Field belongs to an old/different template + // version would not appear in the ReplyElement and angular's PUT + // would not touch it. + var caseChecklistIds = await sdkDbContext.CheckLists + .Where(cl => cl.WorkflowState != Constants.WorkflowStates.Removed) + .Where(cl => cl.Id == foundCase.CheckListId + || cl.ParentId == foundCase.CheckListId + || (cl.ParentId != null + && sdkDbContext.CheckLists + .Where(p => p.WorkflowState != Constants.WorkflowStates.Removed) + .Any(p => p.Id == cl.ParentId + && p.ParentId == foundCase.CheckListId))) + .Select(cl => cl.Id) + .ToListAsync() + .ConfigureAwait(false); + var numberFieldTypeIds = await sdkDbContext.FieldTypes + .Where(ft => ft.Type == Constants.FieldTypes.Number + || ft.Type == Constants.FieldTypes.NumberStepper) + .Select(ft => ft.Id) + .ToListAsync() + .ConfigureAwait(false); + var emptyFillTargets = await sdkDbContext.FieldValues + .Where(fv => fv.CaseId == foundCase.Id + && fv.Value == null + && fv.WorkflowState != Constants.WorkflowStates.Removed) + .Join(sdkDbContext.Fields + .Where(f => f.WorkflowState != Constants.WorkflowStates.Removed + && f.FieldTypeId.HasValue + && numberFieldTypeIds.Contains(f.FieldTypeId.Value) + && caseChecklistIds.Contains((int)f.CheckListId)), + fv => fv.FieldId, + f => f.Id, + (fv, f) => fv.Id) + .ToListAsync() + .ConfigureAwait(false); + if (emptyFillTargets.Count > 0) + { + var emptyPairs = emptyFillTargets + .Select(id => $"{id}|") + .ToList(); + var languageForBatch = await sdkDbContext.Languages + .FirstAsync() + .ConfigureAwait(false); + await core.CaseUpdate(caseId, emptyPairs, []) + .ConfigureAwait(false); + await core.CaseUpdateFieldValues(caseId, languageForBatch) + .ConfigureAwait(false); + } + foundCase.DoneAtUserModifiable = dayDoneAt; foundCase.DoneAt = dayDoneAt; foundCase.SiteId = sdkSiteId; From 1ba2f8c7fa275980ff12b387b6513a0d25dff980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 7 May 2026 19:03:49 +0200 Subject: [PATCH 18/22] fix(opgaver): mirror angular FieldValue write + extension format on UploadPhoto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity harness s_photo_upload_delete caught: mobile's UploadPhoto wrote only to UploadedDatas + Cases.Custom JSON, while angular's AddNewImage wrote to UploadedDatas + FieldValues. Net effect: a photo uploaded via mobile was invisible to the angular admin (and vice versa) because the read paths look at different storage. Now mirrors angular's FieldValues write while keeping Cases.Custom for the existing mobile read path (backward compat). Extension format normalized to no-leading-dot ("png" not ".png") matching angular's FileName.Split(".").Last() shape. FileName now follows angular's two-phase Create-then-Update rename, and FileLocation is populated with the same intermediate-path shape (Path.GetTempPath()/cases-temp-files ticks) that angular writes — column metadata only; mobile remains S3-only for the actual bytes. Discovers the Picture-typed FieldId by walking the case's CheckList descendant tree (BFS), matching the harness picker and the angular UI which passes the fieldId from the rendered template. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 160 +++++++++++++++++- 1 file changed, 152 insertions(+), 8 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 9398ba31f..f3677696d 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -1909,10 +1909,16 @@ public override async Task UploadPhoto( } var contentType = meta.ContentType?.Trim() ?? string.Empty; + // Extension is stored without a leading dot to match angular's + // EFormFilesController.AddNewImage at line 299: + // Extension = newFile.FileName.Split(".").Last() + // which yields e.g. "png" / "jpg". The flutter-eform parity harness + // photo scenario flagged the prior ".png"/".jpg" form as a column-level + // divergence on UploadedDatas / UploadedDataVersions. var extension = contentType switch { - "image/jpeg" or "image/jpg" => ".jpg", - "image/png" => ".png", + "image/jpeg" or "image/jpg" => "jpg", + "image/png" => "png", _ => throw new RpcException(new Status(StatusCode.InvalidArgument, "content_type must be image/jpeg, image/jpg, or image/png.")) }; @@ -2043,24 +2049,89 @@ public override async Task UploadPhoto( } ms.Position = 0; + // FileLocation mirrors angular's intermediate-file path shape + // (EFormFilesController.AddNewImage line 282/298: + // Path.Combine(Path.GetTempPath(), "cases-temp-files", + // $"{DateTime.Now.Ticks}.{ext}") + // ). Mobile is S3-only — no local file is materialised — but + // the column is populated with the same string shape so the + // parity harness sees identical UploadedDatas.FileLocation + // metadata. The path is column metadata only; nothing on the + // mobile read path inspects it as a filesystem location. + var fileLocation = Path.Combine( + Path.GetTempPath(), + "cases-temp-files", + $"{DateTime.Now.Ticks}.{extension}"); + + // FileName is written in two phases to mirror angular line 297 + 302 + // (EFormFilesController.AddNewImage): + // phase 1 (Create): FileName = $"{hash}.{ext}" + // phase 2 (Update): FileName = $"{Id}_{FileName}" + // The initial UploadedDataVersions row therefore carries the + // hash-only form, the second version carries "_.". + // Both versions are visible to the parity harness; matching the + // two-phase shape collapses the column-level divergence on + // UploadedDataVersions. var uploadedData = new SdkUploadedData { Checksum = checksum, - FileName = string.Empty, - FileLocation = string.Empty, + FileName = $"{checksum}.{extension}", + FileLocation = fileLocation, Extension = extension }; await uploadedData.Create(sdkDbContext).ConfigureAwait(false); - var fileName = $"{uploadedData.Id}_{checksum}{extension}"; + var fileName = $"{uploadedData.Id}_{uploadedData.FileName}"; uploadedData.FileName = fileName; await uploadedData.Update(sdkDbContext).ConfigureAwait(false); await core.PutFileToS3Storage(ms, fileName).ConfigureAwait(false); - // 6. Update Case.Custom envelope: replace existing entry at + // 6. Mirror angular's FieldValues row insert. + // EFormFilesController.AddNewImage at line 306-316 creates a NEW + // FieldValue row per uploaded photo (not an upsert) bound to the + // Picture-typed Field on the case's CheckList tree: + // new FieldValue { + // FieldId, CaseId, CheckListId, WorkerId, + // DoneAt = DateTime.UtcNow, + // UploadedDataId = newUploadedData.Id + // }.Create(sdkDbContext); + // The angular path has the fieldId in hand because the UI passes + // it; the mobile UploadPhotoMeta does not, so we discover it by + // walking the case's CheckList descendant tree (BFS) for the + // first FieldType=Picture field — same lookup the parity harness + // picker performs (s_photo_upload_delete._findPictureFieldId). + // + // We keep this write IN ADDITION to the existing Cases.Custom + // envelope update below, so the mobile read path (which reads + // from Cases.Custom) is not regressed; both writes coexist. + var pictureFieldId = await FindPictureFieldIdAsync( + sdkDbContext, foundCase.CheckListId) + .ConfigureAwait(false); + if (pictureFieldId > 0) + { + var pictureField = await sdkDbContext.Fields + .FirstOrDefaultAsync(f => f.Id == pictureFieldId) + .ConfigureAwait(false); + if (pictureField != null) + { + var fieldValue = new Microting.eForm.Infrastructure.Data.Entities.FieldValue + { + FieldId = pictureField.Id, + CaseId = foundCase.Id, + CheckListId = pictureField.CheckListId, + WorkerId = foundCase.WorkerId, + DoneAt = DateTime.UtcNow, + UploadedDataId = uploadedData.Id + }; + await fieldValue.Create(sdkDbContext).ConfigureAwait(false); + } + } + + // 7. Update Case.Custom envelope: replace existing entry at // slot if present (soft-deleting its UploadedData row), then - // append the new tuple. + // append the new tuple. Kept for backward compatibility with + // the existing mobile read path. var commentAtUtc = meta.ClientTsUnix > 0 ? DateTimeOffset.FromUnixTimeSeconds(meta.ClientTsUnix).UtcDateTime : DateTime.UtcNow; @@ -2095,7 +2166,7 @@ public override async Task UploadPhoto( foundCase.Custom = SerializeEnvelopeOrEmpty(envelope); await foundCase.Update(sdkDbContext).ConfigureAwait(false); - // 7. Echo the new UploadedData id as the storage_id so the + // 8. Echo the new UploadedData id as the storage_id so the // client can address subsequent reads / removes. return new UploadPhotoResponse { @@ -2782,4 +2853,77 @@ select fo.Key // Step 3 — no match; pass through. return rawValue; } + + /// + /// BFS-walk the CheckList descendant tree rooted at + /// and return the Id of the first FieldType.Picture field found. + /// Returns 0 when none exists. + /// + /// Mirrors the harness picker + /// (s_photo_upload_delete._findPictureFieldId) so the field bound by + /// UploadPhoto's mirrored FieldValue write matches the field the + /// angular UI passes via EFormFilesController.AddNewImage(fieldId, ...). + /// + private static async Task FindPictureFieldIdAsync( + Microting.eForm.Infrastructure.MicrotingDbContext sdkDbContext, + int? rootCheckListId) + { + if (rootCheckListId == null || rootCheckListId.Value <= 0) + { + return 0; + } + + var pictureFieldTypeId = await sdkDbContext.FieldTypes + .Where(ft => ft.Type == Microting.eForm.Infrastructure.Constants.Constants.FieldTypes.Picture) + .Select(ft => (int?)ft.Id) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + if (pictureFieldTypeId == null || pictureFieldTypeId.Value <= 0) + { + return 0; + } + + var queue = new Queue(); + var seen = new HashSet(); + queue.Enqueue(rootCheckListId.Value); + seen.Add(rootCheckListId.Value); + + while (queue.Count > 0) + { + var clId = queue.Dequeue(); + + var fieldId = await sdkDbContext.Fields + .Where(f => f.CheckListId == clId + && f.FieldTypeId == pictureFieldTypeId.Value + && (f.WorkflowState == null + || f.WorkflowState != Microting.eForm.Infrastructure.Constants.Constants.WorkflowStates.Removed)) + .OrderBy(f => f.DisplayIndex) + .ThenBy(f => f.Id) + .Select(f => (int?)f.Id) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + if (fieldId != null && fieldId.Value > 0) + { + return fieldId.Value; + } + + var children = await sdkDbContext.CheckLists + .Where(cl => cl.ParentId == clId + && (cl.WorkflowState == null + || cl.WorkflowState != Microting.eForm.Infrastructure.Constants.Constants.WorkflowStates.Removed)) + .Select(cl => cl.Id) + .ToListAsync() + .ConfigureAwait(false); + + foreach (var childId in children) + { + if (seen.Add(childId)) + { + queue.Enqueue(childId); + } + } + } + + return 0; + } } From 1cd01132de5a274370d990787fc65ce7fa8f5a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 8 May 2026 05:42:43 +0200 Subject: [PATCH 19/22] fix(opgaver): propagate compliance ids on retracted recurrences + fallback can find retracted cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related defects surfaced during a live device test of compliance 9810 / case 17701 (a retracted missed-deadline rotation that completes correctly in the angular admin but failed from the mobile app): 1. ListOpgaver's ActionableOnly mode strips retracted compliances from compliancesInWeek but the recurrence loop re-emits the same logical row with ComplianceId=null. Device cached compliance_id=0 in Drift, so subsequent writes went out without IDs and hit the fallback. Fix: side-dict from the stripped compliances, populate ComplianceId + SdkCaseId on the recurrence-emit model. 2. The fallback fuzzy lookup in 4 write handlers excluded retracted cases (WorkflowState != Removed on validCaseIdsForSite), so any payload with compliance_id=0 could never resolve a missed-deadline compliance — even though the success path can revive the case. Fix: drop the Cases.WorkflowState filter; let the fallback find retracted cases. The PK branch and success path already handle revival correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BackendConfigurationCalendarService.cs | 44 +++++++++++++++++++ .../GrpcServices/OpgaverGrpcService.cs | 18 ++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs index d962c229f..2f371b551 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs @@ -72,6 +72,11 @@ public async Task>> GetTasks // emitted to the worker because the corresponding write handlers ("complete", // "comment", etc.) have nothing to bind to and will fail. List compliancesInWeek; + // Bug A fix side-dict — see ActionableOnly branch below for rationale. + // Empty for non-ActionableOnly callers (angular admin REST + CalendarGrpcService); + // the recurrence-emit lookup below tolerates that as a no-op. + Dictionary<(int PlanningId, DateTime Date), (int ComplianceId, int SdkCaseId)> nonActionableByPlanningDate + = new(); if (!requestModel.ActionableOnly) { // Default branch — bit-identical to the pre-c2637800 prefetch. @@ -138,6 +143,33 @@ bool IsComplianceActionable(Compliance compliance) compliancesInWeek = compliancesInWeekAll .Where(IsComplianceActionable) .ToList(); + + // Bug A fix (compliance 9810 / case 17701 retracted-rotation parity): + // when a compliance is filtered out by IsComplianceActionable (retracted SDK + // case, soft-deleted, or status==100), the recurrence-emit loop below STILL + // fires for that planning's occurrence date because compliancePlanningIdsInWeek + // (built from the filtered actionable subset) no longer contains it. Without + // intervention the model emitted by that loop has ComplianceId=null / + // SdkCaseId=null, the device caches compliance_id=0 in Drift, and any + // subsequent CompleteOpgave / SetComment / SetFieldValue / UploadPhoto write + // arrives with compliance_id=0 — which then either fails to resolve (legacy + // payloads) or routes through the fallback fuzzy lookup that historically + // excluded retracted cases (Bug B). + // + // Fix: keep the actionable-only filter behavior intact (the row stays + // expired / non-actionable in the UI; IsFromCompliance stays false on the + // recurrence path) but populate ComplianceId + SdkCaseId from the stripped + // compliance so any device-side write round-trips through the PK lookup + // branch instead of the fuzzy fallback. See investigator notes for commit + // 47f20657 — root cause: ListOpgaver→Drift only ever sees the + // recurrence-emit model when actionability stripping removed the compliance. + nonActionableByPlanningDate = compliancesInWeekAll + .Where(c => !compliancesInWeek.Contains(c)) + .GroupBy(c => (c.PlanningId, c.Deadline.Date)) + // GroupBy + first-wins guards against the (unlikely) case of multiple + // non-actionable compliance rows sharing a (planning, day) tuple. + .ToDictionary(g => g.Key, g => (ComplianceId: g.First().Id, + SdkCaseId: g.First().MicrotingSdkCaseId)); } // Build sets for dedup: by exact date and by planningId (any in-week compliance, @@ -328,6 +360,18 @@ bool IsComplianceActionable(Compliance compliance) Attachments = MapAttachments(arp) }; + // Bug A fix: if a non-actionable compliance was stripped for this + // (planningId, occurrenceDate), propagate its ComplianceId + SdkCaseId + // so any device-side write routes through the PK lookup. Leave + // IsFromCompliance=false (we are on the recurrence path and there is + // no actionable compliance to materialise as a calendar row). + if (nonActionableByPlanningDate.TryGetValue( + (arp.ItemPlanningId, occurrenceDate.Date), out var stripped)) + { + model.ComplianceId = stripped.ComplianceId; + model.SdkCaseId = stripped.SdkCaseId; + } + if (ShouldIncludeTask(model, requestModel)) { result.Add(model); diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index f3677696d..eca08de89 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -1092,9 +1092,17 @@ public override async Task CompleteOpgave( // here while the device cache catches up. // TODO: if a worker has a very large number of cases this list // could grow; consider a JOIN-based query if perf becomes an issue. + // + // Bug B fix (compliance 9810 / case 17701): the + // c.WorkflowState != Removed filter previously hid retracted SDK + // cases here, so any payload arriving with compliance_id=0 (legacy + // clients, or a device whose Drift cached zero IDs because Bug A + // emitted a recurrence-only row) could never resolve a + // missed-deadline / retracted compliance. The PK branch above + // doesn't filter by case state and the success path can revive a + // retracted case, so the fallback should match — drop the filter. var validCaseIdsForSite = await sdkDbContextForCompliance.Cases .Where(c => c.SiteId == sdkSiteId) - .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) .Select(c => c.Id) .ToListAsync() .ConfigureAwait(false); @@ -1483,9 +1491,11 @@ private async Task BuildIdempotentCompleteOpgaveResponse else { // Legacy fuzzy lookup — same shape as main CompleteOpgave. + // Bug B fix: drop the c.WorkflowState != Removed filter so a payload + // with compliance_id=0 can still resolve a retracted/missed-deadline + // compliance. See CompleteOpgave's fallback for the full rationale. var validCaseIdsForSite = await sdkDbContextForCompliance.Cases .Where(c => c.SiteId == sdkSiteId) - .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) .Select(c => c.Id) .ToListAsync() .ConfigureAwait(false); @@ -2474,9 +2484,11 @@ public override async Task SetFieldValue( // Legacy fuzzy lookup — DO NOT remove. See CompleteOpgave. // TODO: if a worker has a very large number of cases this list // could grow; consider a JOIN-based query if perf becomes an issue. + // Bug B fix: drop the c.WorkflowState != Removed filter so a payload + // with compliance_id=0 can still resolve a retracted/missed-deadline + // compliance. See CompleteOpgave's fallback for the full rationale. var validCaseIdsForSite = await sdkDbContext.Cases .Where(c => c.SiteId == sdkSiteId) - .Where(c => c.WorkflowState != Constants.WorkflowStates.Removed) .Select(c => c.Id) .ToListAsync() .ConfigureAwait(false); From 3a4c4db839fe55644eca61ef30f90f096aafe374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 8 May 2026 06:42:20 +0200 Subject: [PATCH 20/22] feat(opgaver): bundle field-value + comment writes onto CompleteOpgave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompleteOpgaveRequest now carries `repeated FieldValueWrite field_values` and `string comment`. Server applies the bundle AFTER case revival (WorkflowState='created') and BEFORE the closure cascade (PlanningCase/PlanningCaseSite Status=100 + core.CaseDelete soft-delete) — same lifecycle window the angular admin path uses (BackendConfigurationCompliancesService.Update lines 223-260). Bundle apply reuses the SetFieldValue handler's helpers verbatim: (caseId, fieldId) → FieldValues.Id lookup, CanonicalizeFieldValueAsync for CheckBox/Select normalization, single batched core.CaseUpdate + core.CaseUpdateFieldValues. Skips field_id <= 0 and missing FieldValue rows so legacy-cached fields don't fail the whole bundle. Comment apply mirrors SetComment's envelope-write shape — non-empty replaces OpgaverComment body verbatim; empty string is treated as "no change" so legacy clients pass through unchanged. Per-RPC SetFieldValue and SetComment handlers remain in place for legacy outbox rows still draining from older app builds. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Protos/opgaver.proto | 24 ++++ .../GrpcServices/OpgaverGrpcService.cs | 120 ++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto index 9ec659f10..3ff58f23b 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto @@ -61,9 +61,33 @@ message CompleteOpgaveRequest { // See UploadPhotoMeta.compliance_id / microting_sdk_case_id. 0 = legacy. int64 compliance_id = 5; int64 microting_sdk_case_id = 6; + // Bundled field-value writes applied AFTER the case is revived + // (WorkflowState='created') and BEFORE the closure cascade + // (PlanningCase/PlanningCaseSite Status=100 + core.CaseDelete soft-delete). + // Empty for legacy clients — server still completes the case the same way. + // Each entry corresponds to one prior SetFieldValue RPC; the bundle + // collapses worker edits into a single round-trip at Complete time. + repeated FieldValueWrite field_values = 7; + // Optional comment write applied alongside field_values, same lifecycle + // window. Empty string ("") means "no comment change" — the existing + // Cases.Custom envelope is left untouched. Non-empty replaces the + // OpgaverComment body verbatim, matching SetComment semantics. + string comment = 8; } message CompleteOpgaveResponse { Opgave opgave = 1; } +// One field-value write bundled into CompleteOpgaveRequest.field_values. +// Mirrors the per-RPC SetFieldValueRequest pair (field_id + value) so the +// server-side handler can reuse the same lookup + canonicalization helpers. +message FieldValueWrite { + // SDK Field.Id — matches SetFieldValueRequest.field_id and FormField.id. + // Server rejects field_id <= 0. + int32 field_id = 1; + // Raw worker-facing value; canonicalized server-side per the existing + // CanonicalizeFieldValueAsync helper (CheckBox + Select normalization). + string value = 2; +} + message SetCommentRequest { string opgave_id = 1; string text = 2; diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index eca08de89..b01110dd5 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -1278,6 +1278,126 @@ await core.CaseUpdateFieldValues(caseId, languageForBatch) } } + // --------------------------------------------------------------- + // Bundled field-value + comment writes (CompleteOpgave bundle). + // + // Applied AFTER revival (foundCase.WorkflowState = Created) so + // the case is alive for FieldValue writes, and BEFORE the + // PlanningCase / PlanningCaseSite cascade + core.CaseDelete + // soft-delete below — same lifecycle window as the angular admin + // path (BackendConfigurationCompliancesService.Update lines 223-260 + // does the field-value PUT then the closure transitions in one + // pass, against an alive case). + // + // Mobile previously emitted N-many SetFieldValue RPCs followed by + // CompleteOpgave; the bundle collapses that to a single round- + // trip. Legacy clients (no field_values, comment="") fall through + // unchanged. SetFieldValue / SetComment RPCs remain available for + // legacy outbox rows still in flight. + // + // Field-value writes reuse the SetFieldValue handler's helpers: + // * (caseId, fieldId) → FieldValues.Id lookup, identical to + // line 2518's query. + // * CanonicalizeFieldValueAsync, identical to line 2552 — same + // CheckBox / Select normalization so values stored via the + // bundle are byte-identical with values stored via per-RPC + // SetFieldValue. + // * core.CaseUpdate(caseId, pipePairs, []) + + // core.CaseUpdateFieldValues — single batched call (not one + // per field) so we get one Versions row per FieldValue row, + // not one per write. + // + // Skipped on field_id <= 0 (proto-default zero value, same + // convention as SetFieldValue's request validation at line 2431) + // or when the (caseId, fieldId) pair has no FieldValues row — + // the latter happens for legacy fields the client cached but + // that no longer exist on the server-side template; silently + // skipping keeps the bundle resilient. + // --------------------------------------------------------------- + if (request.FieldValues.Count > 0) + { + var bundleLanguage = await sdkDbContext.Languages + .FirstAsync() + .ConfigureAwait(false); + var pipePairs = new List(request.FieldValues.Count); + foreach (var fvw in request.FieldValues) + { + if (fvw.FieldId <= 0) + { + // Skip — same convention as SetFieldValue's input + // validation. We don't fail the whole bundle on one + // bad entry; the client may have queued a stale row. + continue; + } + + var fieldValueRowId = await sdkDbContext.FieldValues + .Where(fv => fv.CaseId == foundCase.Id && fv.FieldId == fvw.FieldId) + .Select(fv => fv.Id) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + if (fieldValueRowId == 0) + { + // Legacy field that no longer exists on the case — + // skip rather than fail. Same defensive posture as + // the angular CaseUpdateHelper, which silently + // ignores unknown fieldValueIds. + continue; + } + + var fieldTypeName = await sdkDbContext.Fields + .Where(f => f.Id == fvw.FieldId) + .Join(sdkDbContext.FieldTypes, + f => f.FieldTypeId, + ft => ft.Id, + (f, ft) => ft.Type) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + + var rawValue = fvw.Value ?? string.Empty; + var canonicalValue = await CanonicalizeFieldValueAsync( + sdkDbContext, fvw.FieldId, fieldTypeName, rawValue, bundleLanguage.Id) + .ConfigureAwait(false); + + pipePairs.Add($"{fieldValueRowId}|{canonicalValue}"); + } + + if (pipePairs.Count > 0) + { + var ok = await core.CaseUpdate(caseId, pipePairs, []) + .ConfigureAwait(false); + if (!ok) + { + logger.LogError( + "OpgaverGrpcService.CompleteOpgave (bundle): " + + "Core.CaseUpdate returned false for opgave {OpgaveId} caseId {CaseId} bundleSize {N}", + opgaveId, caseId, pipePairs.Count); + throw new RpcException(new Status(StatusCode.FailedPrecondition, + "CompleteOpgave bundle: field-value persistence failed in SDK CaseUpdate")); + } + await core.CaseUpdateFieldValues(caseId, bundleLanguage) + .ConfigureAwait(false); + } + } + + // Bundled comment write — empty string means "no change", matching + // proto3 default-value semantics. Non-empty replaces the + // OpgaverComment body verbatim (same shape SetComment writes at + // line 1786-1796 above). We intentionally do NOT trim the empty + // case to "" → null here: an explicit comment-clear is queued by + // the legacy SetComment outbox path, not by the bundle. + if (!string.IsNullOrEmpty(request.Comment)) + { + var existingEnvelope = TryParseEnvelope(foundCase.Custom); + var nextEnvelope = existingEnvelope ?? new OpgaverCustomEnvelope(); + nextEnvelope.OpgaverComment = new OpgaverCommentBody + { + Text = request.Comment, + TsUnix = ToUnixSeconds(doneAtUtc), + }; + foundCase.Custom = SerializeEnvelopeOrEmpty(nextEnvelope); + await foundCase.Update(sdkDbContext).ConfigureAwait(false); + } + // Mirror the post-update sequence from // BackendConfigurationCompliancesService.Update (lines 307-335): // set Status=100 on PlanningCaseSite + parent PlanningCase so the From 478df188cb47dd46ec1f374bfcfe09f626a6a2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 8 May 2026 07:22:08 +0200 Subject: [PATCH 21/22] fix(opgaver): materialize FieldValues via CaseRead before bundle apply Mobile's atomic-save bundle silently dropped writes when FieldValues rows hadn't been materialized for the case (case never read via GET /compliances/cases on the admin browser side). Angular's PUT runs after a GET that materializes via Core.CaseRead; mobile's ListTaskTracker doesn't trigger that path. CompleteOpgave now calls core.CaseRead before the per-field lookup, matching angular's lazy-materialization mechanism. Field values typed on the device now reach the SDK and surface in reports. Hotfix on top of the atomic-save commit (3a4c4db8). Full convergence to angular's BackendConfigurationCompliancesService.Update tracked as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index b01110dd5..09512c8c5 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -1319,6 +1319,24 @@ await core.CaseUpdateFieldValues(caseId, languageForBatch) var bundleLanguage = await sdkDbContext.Languages .FirstAsync() .ConfigureAwait(false); + + // Mirror angular's GET /api/.../compliances/cases path which calls + // Core.CaseRead before the per-field PUT. CaseRead lazy-materializes + // the per-field FieldValues rows for the case (eform-sdk + // SqlController.cs:CheckRead lines 1690-1723 / 1760-1789 — for any + // (caseId, fieldId) pair without an existing row, a new FieldValues + // row is created via fieldValue.Create(db)). Without this call, + // the per-field row lookup below returns 0 (no row) for cases that + // have never been read by an admin browser session — as is the + // case for ListTaskTracker-fetched opgaver — and every typed + // value silently drops. + // + // Uses the (int id, Language) CaseRead overload (Core.cs:1132 → + // SqlController.CheckRead:1557) which takes the SDK Cases.Id — + // the same call shape as BackendConfigurationCompliancesService.Read + // at line 173: core.CaseRead(id, language). + await core.CaseRead(foundCase.Id, bundleLanguage).ConfigureAwait(false); + var pipePairs = new List(request.FieldValues.Count); foreach (var fvw in request.FieldValues) { From 90a250a96b287c7e3ccf93e57254216a346de655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Fri, 8 May 2026 07:41:34 +0200 Subject: [PATCH 22/22] fix(opgaver): DoneAt = compliance.Deadline (not DateTime.UtcNow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile's CompleteOpgave was stamping DoneAt with the server's wall clock (DateTime.UtcNow / request.ClientTsUnix), which meant a worker completing a missed-deadline rotation produced reports dated today rather than the rotation's actual scheduled deadline. Now derives doneAtUtc = compliance.Deadline (with a != default fallback to DateTime.UtcNow for legacy / partially-populated rows, mirroring the existing pattern at lines 1681 / 1938 / 2734) once at the top of the closure and propagates to Case.DoneAt / DoneAtUserModifiable, PlanningCase.MicrotingSdkCaseDoneAt, PlanningCaseSite.MicrotingSdkCaseDoneAt, and the dayKey used for the post-completion calendar refresh. The bundled comment write keeps wall-clock semantics via a separate commentAtUtc local — the comment TsUnix tracks when the worker actually authored the comment on the device, which is genuinely distinct from when the rotation was scheduled (Deadline). Core.CaseUpdate / CaseUpdateFieldValues do NOT accept a doneAt parameter (SqlController.FieldValueUpdate writes Value only, no DoneAt stamp), so no changes are needed for FieldValue rows — they don't carry a DoneAt in this code path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GrpcServices/OpgaverGrpcService.cs | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs index 09512c8c5..a84d06814 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -1121,15 +1121,37 @@ public override async Task CompleteOpgave( $"Opgave {opgaveId} has no pending compliance — there is no SDK case to complete.")); } - // Convert client_ts_unix (seconds) → UTC DateTime. Fall back to - // server-side now if the client didn't send a usable timestamp. - DateTime doneAtUtc = request.ClientTsUnix > 0 - ? DateTimeOffset.FromUnixTimeSeconds(request.ClientTsUnix).UtcDateTime + // DoneAt is set to compliance.Deadline (the rotation's scheduled + // date), NOT the server wall clock. When a worker completes a + // missed-deadline rotation, the report should be dated the rotation's + // actual scheduled deadline rather than today — otherwise a worker + // closing a Monday rotation on Wednesday produces Wednesday-dated + // reports, which misaligns with the angular admin "filled cases" view + // (queries PlanningCases WHERE MicrotingSdkCaseDoneAt >= fromDate) + // and breaks per-rotation history. + // + // Compliance.Deadline is non-nullable (DateTime, not DateTime?) but + // can be default(DateTime) on legacy / partially-populated rows; the + // != default guard mirrors lines 1681 / 1938 / 2734 in this file. + // Falling back to DateTime.UtcNow keeps the previous behaviour for + // those edge cases. request.ClientTsUnix is now ignored for DoneAt + // purposes — it is preserved only for the comment TsUnix audit trail + // (when the comment was authored on the device), distinct from when + // the rotation was scheduled. + DateTime doneAtUtc = compliance.Deadline != default + ? compliance.Deadline : DateTime.UtcNow; // BackendConfigurationCompliancesService.Update truncates DoneAt to // midnight UTC of that calendar date — keep parity. var dayDoneAt = new DateTime(doneAtUtc.Year, doneAtUtc.Month, doneAtUtc.Day, 0, 0, 0, DateTimeKind.Utc); + // Wall-clock "when the worker actually closed this on the device" — + // distinct from doneAtUtc (the deadline). Used only for the comment + // TsUnix audit trail below; DoneAt fields all use dayDoneAt / + // doneAtUtc per the user directive (DoneAt = Deadline). + DateTime commentAtUtc = request.ClientTsUnix > 0 + ? DateTimeOffset.FromUnixTimeSeconds(request.ClientTsUnix).UtcDateTime + : DateTime.UtcNow; var caseId = compliance.MicrotingSdkCaseId; @@ -1410,7 +1432,7 @@ await core.CaseUpdateFieldValues(caseId, bundleLanguage) nextEnvelope.OpgaverComment = new OpgaverCommentBody { Text = request.Comment, - TsUnix = ToUnixSeconds(doneAtUtc), + TsUnix = ToUnixSeconds(commentAtUtc), }; foundCase.Custom = SerializeEnvelopeOrEmpty(nextEnvelope); await foundCase.Update(sdkDbContext).ConfigureAwait(false);