Skip to content

fix(opgaver-grpc): update PlanningCaseSite + PlanningCase on completion#796

Merged
renemadsen merged 22 commits intostablefrom
fix/opgaver-complete-update-planning-rows
May 9, 2026
Merged

fix(opgaver-grpc): update PlanningCaseSite + PlanningCase on completion#796
renemadsen merged 22 commits intostablefrom
fix/opgaver-complete-update-planning-rows

Conversation

@renemadsen
Copy link
Copy Markdown
Member

Summary

After completing an opgave from the Flutter device, the case appeared
correctly in the SDK Cases table (Status=100, DoneAtUserModifiable set)
but never showed up in the admin "filled cases" view.

Root cause: PlanningCaseSites and PlanningCases remained at Status=66/77
with MicrotingSdkCaseDoneAt=NULL. The admin view's query
(Status=100 AND DoneAt >= fromDate) excluded them.

Fix

After the existing Case row update in OpgaverGrpcService.CompleteOpgave,
mirror the post-update sequence from BackendConfigurationCompliancesService.Update (lines 307-335):

  • Look up PlanningCaseSite by CreatedAt.Date == compliance.StartDate.Date && PlanningId == compliance.PlanningId
  • Set Status=100, MicrotingSdkCaseId, MicrotingSdkCaseDoneAt=foundCase.DoneAt, DoneByUserId=(int)sdkSiteId, DoneByUserName (resolved from sdkDbContext.Sites)
  • Persist via .Update()
  • Look up parent PlanningCase, set Status=100, MicrotingSdkCaseDoneAt=foundCase.DoneAt, WorkflowState=Processed, DoneByUserId/Name
  • Persist via .Update()

Also injects ItemsPlanningPnDbContext into the primary constructor.

Still deferred (separate sub-project)

  • Property.ComplianceStatus / ComplianceStatusThirty recomputation
  • CaseUpdateDelegate invocation
  • core.CaseDelete of the device-side case

Constraint

No hard deletes anywhere. The existing Compliance soft-delete continues
to use entity.Delete(dbContext).

Test plan

  • dotnet build clean (0 errors, 145 pre-existing warnings)
  • Device verification: complete an opgave from Flutter, confirm it
    appears in the admin filled-cases view immediately

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 6, 2026 12:24
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a backend consistency gap in the Backend Configuration plugin’s mobile “Opgaver” gRPC completion flow so that completing an opgave on a Flutter device updates the corresponding Items Planning entities, allowing the admin “filled cases” view to include the completed case.

Changes:

  • Injects ItemsPlanningPnDbContext into OpgaverGrpcService.
  • After completing the SDK Case, updates PlanningCaseSite and its parent PlanningCase to Status=100 and sets MicrotingSdkCaseDoneAt / done-by metadata to match the completed SDK case.
  • Updates service documentation to describe the newly-closed parity gap (and what is still deferred).

Comment on lines +1000 to +1015
.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);
Comment on lines +974 to +979
// 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
Comment on lines +979 to +982
var siteName = (await sdkDbContext.Sites
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
.FirstOrDefaultAsync(x => x.Id == sdkSiteId)
.ConfigureAwait(false))?.Name ?? string.Empty;
Comment on lines +1013 to +1015

planningCaseSite.PlanningCaseId = planningCase.Id;
await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false);
renemadsen and others added 21 commits May 6, 2026 15:02
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…, fieldId)

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 45b0dc4) 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) <noreply@anthropic.com>
…ploadPhoto

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) <noreply@anthropic.com>
…lback can find retracted cases

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 (3a4c4db). Full
convergence to angular's BackendConfigurationCompliancesService.Update
tracked as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@renemadsen renemadsen merged commit 90a250a into stable May 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants