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/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 3052b5111..3ff58f23b 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); @@ -30,6 +31,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 +47,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,13 +58,43 @@ 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; + // 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; 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 +103,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; } @@ -79,6 +123,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; @@ -122,6 +176,23 @@ 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; + // 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 f83afe408..2f371b551 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,122 @@ 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) - .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) + // Pre-load compliance dates to avoid duplicates between occurrence expansion and compliances. + // + // 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; + // 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. + 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); + + 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(); + + // 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, + // 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( @@ -251,6 +360,18 @@ public async Task>> GetTasks 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); @@ -2186,4 +2307,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 6c5d32603..a84d06814 100644 --- a/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs +++ b/eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/GrpcServices/OpgaverGrpcService.cs @@ -20,7 +20,10 @@ 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; using SdkUploadedData = Microting.eForm.Infrastructure.Data.Entities.UploadedData; using SdkDataItem = Microting.eForm.Infrastructure.Models.DataItem; using SdkElement = Microting.eForm.Infrastructure.Models.Element; @@ -64,30 +67,37 @@ 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): /// +/// 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) — see -/// BackendConfigurationCompliancesService.cs:307-318. +/// DoneByUserName) — mirrors lines 307-318. /// PlanningCase row update (Status=100, -/// WorkflowState=Processed) — lines 320-335. +/// 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): +/// /// Property.ComplianceStatus / /// 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. /// -/// 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 +106,7 @@ public class OpgaverGrpcService( IGrpcSiteResolver siteResolver, IEFormCoreService coreHelper, BackendConfigurationPnDbContext dbContext, + ItemsPlanningPnDbContext itemsPlanningPnDbContext, ILogger logger) : Opgaver.OpgaverBase { @@ -190,7 +201,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); @@ -235,7 +247,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. }; @@ -250,6 +272,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 @@ -517,7 +629,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: @@ -813,7 +941,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); @@ -848,7 +977,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); @@ -884,22 +1017,27 @@ 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 - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) + .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed || x.WorkflowState == null) .FirstOrDefaultAsync(x => x.Id == opgaveId) .ConfigureAwait(false); @@ -921,12 +1059,61 @@ 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. - var compliance = await dbContext.Compliances - .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) - .Where(x => x.PlanningId == arp.ItemPlanningId) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + // + // 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(); + 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. + // + // 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) + .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) { @@ -934,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; @@ -951,8 +1160,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); @@ -962,12 +1172,361 @@ 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; 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); + } + } + + // --------------------------------------------------------------- + // 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); + + // 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) + { + 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(commentAtUtc), + }; + 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 + // 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 || 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 || x.WorkflowState == null) + .FirstOrDefaultAsync(x => + x.MicrotingSdkCaseId == foundCase.Id) + .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); + } + + // 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 @@ -982,7 +1541,8 @@ public override async Task CompleteOpgave( WeekEnd = dayKey, BoardIds = [], TagNames = [], - SiteIds = [] + SiteIds = [], + ActionableOnly = true }).ConfigureAwait(false); var refreshedTask = refreshed.Success && refreshed.Model != null @@ -1025,6 +1585,176 @@ 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. + // 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) + .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. /// @@ -1105,7 +1835,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); @@ -1143,11 +1873,40 @@ 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. - var compliance = await dbContext.Compliances - .Where(x => x.PlanningId == arp.ItemPlanningId) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + // + // 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(); + 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 @@ -1163,8 +1922,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) @@ -1207,7 +1967,8 @@ public override async Task SetComment( WeekEnd = dayKey, BoardIds = [], TagNames = [], - SiteIds = [] + SiteIds = [], + ActionableOnly = true }).ConfigureAwait(false); var refreshedTask = refreshed.Success && refreshed.Model != null @@ -1318,17 +2079,23 @@ 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.")) }; // 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); @@ -1349,11 +2116,40 @@ 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. - var compliance = await dbContext.Compliances - .Where(x => x.PlanningId == arp.ItemPlanningId) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + // + // 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(); + 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) { @@ -1361,9 +2157,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); @@ -1426,24 +2219,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; @@ -1478,7 +2336,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 { @@ -1518,7 +2376,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); @@ -1536,11 +2394,38 @@ public override async Task RemovePhoto( "Caller has no PropertyWorker access to the opgave's property.")); } - var compliance = await dbContext.Compliances - .Where(x => x.PlanningId == arp.ItemPlanningId) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + // 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(); + 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) { @@ -1548,9 +2433,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); @@ -1715,7 +2597,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); @@ -1733,13 +2615,51 @@ 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. - var compliance = await dbContext.Compliances - .Where(x => x.PlanningId == arp.ItemPlanningId) - .OrderBy(x => x.Deadline) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + // Resolve the compliance for this ARP's planning. + // + // 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. + var core = await coreHelper.GetCore().ConfigureAwait(false); + var sdkDbContext = core.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 — 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) + .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) { @@ -1748,26 +2668,80 @@ 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); - // 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}.")); + } + + // 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 { - $"{request.FieldId}|{request.Value ?? string.Empty}" + $"{fieldValueRowId}|{canonicalValue}" }; 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, @@ -1789,7 +2763,8 @@ public override async Task SetFieldValue( WeekEnd = dayKey, BoardIds = [], TagNames = [], - SiteIds = [] + SiteIds = [], + ActionableOnly = true }).ConfigureAwait(false); var refreshedTask = refreshed.Success && refreshed.Model != null @@ -1886,4 +2861,241 @@ 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; + } + + /// + /// 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; + } }