Skip to content

feat(opgaver): UploadPhoto fieldId binding + Complete response parity#798

Merged
renemadsen merged 28 commits into
stablefrom
feat/upload-photo-field-id
May 9, 2026
Merged

feat(opgaver): UploadPhoto fieldId binding + Complete response parity#798
renemadsen merged 28 commits into
stablefrom
feat/upload-photo-field-id

Conversation

@renemadsen
Copy link
Copy Markdown
Member

Summary

  • UploadPhoto honors meta.FieldId: per-field photo binding at the SDK FieldValues layer. Adds field_id = 7 to UploadPhotoMeta (proto) and resolves it to a Picture-typed Field row in the case's CheckList tree before inserting the FieldValue. Validates both invariants (Picture type + tree membership) and rejects with InvalidArgument if either fails — closes a corruption window where a buggy/malicious client could plant referentially-broken (CaseId, CheckListId, FieldId) triples. Falls back to the legacy slot-based FindPictureFieldIdAsync BFS when meta.FieldId == 0 (backward-compat for older clients).
  • CompleteOpgaveResponse parity: response now populates Fields, Attachments, and Comment so the mobile client doesn't blank Drift on a successful Complete. Mirrors the REST PUT /compliances/cases atomic-save semantics. Idempotent path uses the same shape.

Verification

  • Pre-merge dual-subagent gate clean (code-reviewer caught the missing field-id validation; fix landed in 161d174d. Simplifier reviewed full diff: ship-as-is, four-site BuildOpgaveProto duplication accepted as a separate cleanup).
  • Phase 1 photo-scoping harness (lives in flutter-eform dev_loop/photo_scoping_harness/) PASS against this branch: two photos uploaded to two distinct (complianceId, fieldId) triples, SQL on 420_SDK.FieldValues+UploadedDatas confirms Q1 (2 correct rows), Q2 (0 cross-leak), Q3 (2 distinct checksums). Storage IDs 700/701 on the latest run.
  • dotnet build against the host's embedded copy: 0 errors, only pre-existing warnings.

Dependent PR

  • flutter-eform PR Bump NUnit3TestAdapter from 6.1.0 to 6.2.0 #740 (https://github.com/microting/flutter-eform/pull/740) — the mobile consumer that sends meta.FieldId and renders the Fields/Attachments/Comment round-trip from this response shape. Should be merged together (or this one first, then flutter-eform) — flutter-eform is fine to land on a stable-deployed backend that hasn't picked this up yet (the field is optional + handler falls back), but W3 photo-scoping only fully functions once this is on stable.

Known follow-ups (not in this PR)

  • BuildOpgaveProto helper-lift — four-site duplication across CompleteOpgave / SetComment / SetFieldValue / StreamOpgaveChanges path response builders. Each call site has slightly different shape (TaskIsExpired, Completed override, etc.) so a helper would need 5–6 named params; flagged for a separate refactor.
  • BackendConfiguration.Pn.Tests: no unit/integration tests added for the new UploadPhoto path or the Complete response shape. Test gap; should land before next round of changes touching this surface.
  • LoadFieldsByTaskIdAsync after core.CaseDelete ordering: depending on the SDK version's soft-delete semantics for core.CaseRead, the freshly-soft-deleted case may not return its field tree, falling back to empty Fields. Worth a parity-harness test in the next iteration.

Test plan

  • dual-gate (code-review + code-simplifier)
  • Phase 1 harness PASS against new DLL
  • dotnet build clean
  • Backend integration test on a CI-triggered run
  • Manual smoke against angular admin to confirm Complete response shape doesn't regress that consumer

🤖 Generated with Claude Code

renemadsen and others added 27 commits May 6, 2026 14:24
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>
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>
Crash at report-table.component.ts:129 when caseField.value is null
/ undefined / empty for number+date columns: TypeError on
value.replace and RangeError from parseISO(invalid). Mobile-completed
cases sometimes carry empty FieldValues (per SDK convention) which
the formatter wasn't guarding against.

Coalesces value to '' at the top, skips number .replace and date
parseISO when value is empty / invalid, adds explicit '' return for
the 'unchecked' fall-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous null-guard (commit 7d66aaa) used `?? ''` which only catches
null/undefined — non-string values (numbers, booleans) passed through
to parseISO which then threw "dateString.split is not a function".

Now coerces via String(...) so any non-null value becomes a proper
string before parseISO sees it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nding

Adds `int32 field_id = 7` to UploadPhotoMeta. When > 0 the handler binds
the new FieldValue row to the client-specified Field.Id; when 0 it falls
back to the prior BFS-first-Picture discovery so legacy outbox payloads
in flight on existing devices keep working.

Caller is already auth-scoped to the opgave's property via PropertyWorker
check; out-of-tree field_id is at worst a self-inflicted misbinding the
same worker can correct with another upload, so we don't reject. The
Cases.Custom envelope write is unchanged.

Mirrors angular's per-field photo flow: the angular admin's
EFormFilesController.AddNewImage already takes (fieldId, caseId, file)
and creates a FieldValue row with UploadedDataId. The mobile path now
matches that contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… comment

The CompleteOpgave handler was constructing the response Opgave via
inline `new Opgave { ... }` initializers and never populating Fields,
Attachments, Comment, EformId, ComplianceId, or MicrotingSdkCaseId.
Mobile's applyServerOpgave merges the response into Drift via
upsertOpgaver, which overwrites the cached form fields with the proto3
defaults (empty list, empty string), making form-field values
"disappear" from the UI on Complete.

Both response paths fixed:
  - Main path (CompleteOpgave): refreshedTask branch now reuses the
    same LoadEnvelopeByTaskIdAsync + LoadFieldsByTaskIdAsync +
    PopulateAttachments helpers as ListOpgaver / ListTaskTracker /
    LoadOpgaverAsync, so the response carries post-bundle truth.
  - Idempotent path (BuildIdempotentCompleteOpgaveResponse): same
    helper-call sequence applied. On outbox retries, the second
    response no longer empties the cache the first response correctly
    populated.

Order is preserved: bundle (CaseUpdate field values + comment write +
PlanningCase Status=100 + core.CaseDelete) is fully applied before the
response is constructed; the helpers re-read post-bundle state.

The synthesized "no longer actionable" branches (returned with empty
fields when refreshedTask is null) are unchanged — the mobile client's
applyServerOpgave drops the row from cache when proto.id is empty/0.

Known follow-up flagged by simplifier: the same `Opgave { ... } +
PopulateAttachments + Fields.AddRange` block now appears in 4 sites
(ListOpgaver, ListTaskTracker, LoadOpgaverAsync, CompleteOpgave). A
BuildOpgaveFromCalendarTask helper would prevent future drift; deferred
to a separate cleanup commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…in case tree

Code-review found the new meta.FieldId > 0 path was looking up Fields by Id
without verifying (a) the field is Picture-typed and (b) it lives inside
the case's CheckList descendant tree. Both invariants were already enforced
by FindPictureFieldIdAsync for the legacy slot-based fallback; the new
client-id branch bypassed them, so a bogus meta.FieldId could land an
UploadedData reference on a Text/Number/Comment field's row or on a field
in an unrelated CheckList — corrupting the (CaseId, CheckListId, FieldId)
triple persisted to FieldValues.

Adds ValidateClientPictureFieldIdAsync — a targeted variant of the BFS
that loads the candidate field once (id + type filter + not-Removed) and
walks the CheckList parent/child tree from foundCase.CheckListId, returning
the field id iff its CheckListId is reachable from the root.

UploadPhoto's resolution path now:
  - meta.FieldId > 0 -> validate; on rejection throw RpcException with
    Status.InvalidArgument carrying the rejected id and the case's
    root CheckListId for debuggability. Fail loud — matches the cycle's
    fail-loud principle and consistent with the existing slot-range
    rejection in this RPC.
  - meta.FieldId == 0 -> legacy BFS unchanged.

No proto change; no API surface change. Test coverage for this guard is
tracked as a follow-up in the PR description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 8, 2026 16:48
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

Adds mobile-worker parity improvements to the Backend Configuration plugin’s gRPC “Opgaver” surface by stabilizing task identity, expanding response payloads, and aligning photo uploads and completion behavior more closely with the existing Angular-admin/REST flows.

Changes:

  • Extended the gRPC contract and handlers to round-trip stable identifiers (compliance_id, microting_sdk_case_id) and support a new ListTaskTracker RPC.
  • Updated completion and field-write behavior to return richer CompleteOpgaveResponse payloads (Fields/Attachments/Comment) and to canonicalize/persist field values more consistently.
  • Enhanced photo upload handling to support binding to a specific Picture field via meta.field_id, plus added actionable-only filtering to the calendar-week task query used by mobile.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs Implements stable-ID lookups, richer Complete responses, bundled field writes, per-field photo binding/validation, and adds ListTaskTracker.
eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/IBackendConfigurationCalendarService.cs Adds GetTaskTrackerList(...) API for full property-scoped task tracker results.
eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/BackendConfigurationCalendarService.cs Introduces ActionableOnly mode in week listing and implements GetTaskTrackerList with batched case loading and per-row site filtering.
eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Protos/opgaver.proto Expands the wire contract: new RPC, new IDs on messages, bundled field writes, and task_is_expired.
eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskResponseModel.cs Adds TaskIsExpired to support task-tracker UI semantics.
eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskRequestModel.cs Adds ActionableOnly to control mobile vs admin calendar emission behavior.
eform-client/src/app/plugins/modules/backend-configuration-pn/modules/reports/components/report-table/report-table.component.ts Hardens report-table value formatting (null/empty handling, date parsing validation).

Comment on lines +1225 to +1236
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);
Comment on lines +1453 to +1456
.FirstOrDefaultAsync(x =>
x.MicrotingSdkCaseId == foundCase.Id)
.ConfigureAwait(false);

Comment on lines +2771 to +2772
throw new RpcException(new Status(StatusCode.NotFound,
$"No FieldValue row exists for case {caseId} field {request.FieldId}."));
Comment on lines +3167 to +3173
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);
Comment on lines +3236 to +3265
// BFS the CheckList tree rooted at the case's CheckListId; succeed
// iff candidate.CheckListId is reached.
var queue = new Queue<int>();
var seen = new HashSet<int>();
queue.Enqueue(rootCheckListId.Value);
seen.Add(rootCheckListId.Value);

while (queue.Count > 0)
{
var clId = queue.Dequeue();
if (clId == candidate.CheckListId.Value)
{
return candidate.Id;
}

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);
}
}
…ield-id

# Conflicts:
#	eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/IBackendConfigurationCalendarService.cs
@renemadsen renemadsen merged commit 03258ab into stable May 9, 2026
32 of 33 checks 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