feat(events): eager-deploy future events inline in ListEvents#813
Conversation
…deploy Adds IEventDeployService + log-only stub and wires it into the EventsGrpcService.ListEvents handler. The real deploy pipeline lands in a follow-up commit. Prepares the seam for eager-deploying SDK cases + Compliance rows for future events (today+1, today+2) so flutter-eform's CompleteEvent flow can resolve them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the log-only stub with the real per-rotation deploy: idempotence guard (Compliance lookup), PlanningCase + PlanningCaseSite creation mirroring ItemCaseCreateHandler, _sdkCore.CaseCreate, and Compliance.Create mirroring EformParsedByServerHandler. Runs synchronously inside ListEvents so future-day rotations come back with non-zero complianceId/microtingSdkCaseId and populated fields. No Rebus messages, no Planning state mutation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop duplicate Compliances.AnyAsync inside EnsureComplianceRowAsync; outer guard + duplicate-key catch are the canonical race pattern mirroring EformParsedByServerHandler. - Trim stale "stub" comment from EventsGrpcService.ListEvents — the EventDeployService pipeline is real now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an inline "eager-deploy" pipeline to the ListEvents gRPC handler so that future-day rotations (today+1, today+2) returned to flutter-eform are backed by real PlanningCase/PlanningCaseSite/SDK case/Compliance rows, fixing the UX where future events surface but cannot be completed because their compliance_id/microting_sdk_case_id are zero. The pipeline duplicates logic that previously lived only in scheduler microservice handlers (ItemCaseCreateHandler, EformParsedByServerHandler) and runs synchronously inside the read RPC.
Changes:
- Introduces
IEventDeployService+EventDeployServicethat enumerate calendar rotations in the requested window, skip rotations already backed by aCompliance(AnyAsyncon(PlanningId, Deadline.Date)), and create thePlanningCase/PlanningCaseSite/SDK case/Compliancechain. - Wires the new service into
EventsGrpcService.ListEventsafter the permission gate and before the data fetch. - Registers the new service in
EformBackendConfigurationPlugin.ConfigureServices.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
Services/EventDeployService/IEventDeployService.cs |
New interface for the inline deploy pipeline. |
Services/EventDeployService/EventDeployService.cs |
New implementation: candidate enumeration, idempotence guard, per-rotation deploy mirroring scheduler handlers, with per-row try/catch and a duplicate-key tolerance. |
Services/GrpcServices/EventsGrpcService.cs |
Injects and invokes IEventDeployService from ListEvents before the calendar fetch. |
EformBackendConfigurationPlugin.cs |
DI registration of IEventDeployService → EventDeployService (transient). |
Comments suppressed due to low confidence (1)
eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/EventDeployService/EventDeployService.cs:395
- The comment explicitly preserves a known bug from
EformParsedByServerHandler.cs:179by settingCompliance.PlanningCaseSiteId = planningCaseSite.PlanningCaseId(thePlanningCaseid, not thePlanningCaseSiteid). While intentional for round-trip parity with the canonical writer, copying a known-buggy field assignment into a new write site widens the blast radius of the original bug and makes a future fix harder (now two independent code paths must be migrated atomically). Suggest at minimum filing a tracking issue and adding a// TODOreferencing it so the cleanup is discoverable, rather than codifying the quirk indefinitely.
// The handler mistakenly stores PlanningCaseId here (named
// PlanningCaseSiteId on the column) — see
// EformParsedByServerHandler.cs:179. Preserve that convention
// so the round-trip matches the JSON oracle path.
PlanningCaseSiteId = planningCaseSite.PlanningCaseId
| catch (Exception ex) | ||
| { | ||
| // Duplicate-key races are tolerated — mirrors | ||
| // EformParsedByServerHandler.cs:185-196. | ||
| if (ex.InnerException is { HResult: -2147467259 }) | ||
| { | ||
| logger.LogInformation( | ||
| "EventDeployService: compliance for planning {PlanningId} deadline {Deadline} already exists (race) — skipping", | ||
| planning.Id, rotationDate); | ||
| return; | ||
| } | ||
| throw; | ||
| } |
| var planningCase = new PlanningCase | ||
| { | ||
| PlanningId = planning.Id, | ||
| Status = 66, | ||
| MicrotingSdkeFormId = eformId | ||
| }; | ||
| await planningCase.Create(itemsPlanningPnDbContext).ConfigureAwait(false); | ||
|
|
||
| // 4. Resolve / create PlanningCaseSite. | ||
| // Mirrors ItemCaseCreateHandler.cs:179-194. | ||
| var planningCaseSite = new PlanningCaseSite | ||
| { | ||
| MicrotingSdkSiteId = sdkSiteId, | ||
| MicrotingSdkeFormId = eformId, | ||
| Status = 66, | ||
| PlanningId = planning.Id, | ||
| PlanningCaseId = planningCase.Id | ||
| }; | ||
| await planningCaseSite.Create(itemsPlanningPnDbContext).ConfigureAwait(false); | ||
|
|
||
| // 5. SDK case idempotence guard — mirrors | ||
| // ItemCaseCreateHandler.cs:205. A freshly-created | ||
| // PlanningCaseSite has MicrotingSdkCaseId == 0, so this | ||
| // branch is taken on the deploy path. | ||
| if (planningCaseSite.MicrotingSdkCaseId >= 1) | ||
| { | ||
| // Still ensure the Compliance row exists for this rotation | ||
| // before continuing. | ||
| await EnsureComplianceRowAsync( | ||
| areaRulePlanning, | ||
| planning, | ||
| rotationDate, | ||
| planningCaseSite, | ||
| cancellationToken) | ||
| .ConfigureAwait(false); | ||
| continue; | ||
| } | ||
|
|
||
| // 6. Build mainElement. Mirrors ItemCaseCreateHandler.cs:113-153. | ||
| // KEY DIFFERENCE: EndDate is the rotation we're deploying | ||
| // (not planning.NextExecutionTime), so backfill of a future | ||
| // rotation date stays bounded to that day. | ||
| var mainElement = await sdkCore.ReadeForm(eformId, language).ConfigureAwait(false); | ||
|
|
||
| var planningNameTranslation = await itemsPlanningPnDbContext.PlanningNameTranslation | ||
| .FirstOrDefaultAsync(x => | ||
| x.LanguageId == language.Id && x.PlanningId == planning.Id, | ||
| cancellationToken) | ||
| .ConfigureAwait(false); | ||
| var translation = planningNameTranslation?.Name; | ||
|
|
||
| string folderId = string.Empty; | ||
| if (planning.SdkFolderId.HasValue) | ||
| { | ||
| var folder = await sdkDbContext.Folders | ||
| .FirstOrDefaultAsync(x => x.Id == planning.SdkFolderId.Value, cancellationToken) | ||
| .ConfigureAwait(false); | ||
| folderId = folder?.MicrotingUid?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; | ||
| } | ||
|
|
||
| mainElement.Label = string.IsNullOrEmpty(planning.PlanningNumber) ? "" : planning.PlanningNumber; | ||
| mainElement.StartDate = DateTime.UtcNow; | ||
| if (!string.IsNullOrEmpty(translation)) | ||
| { | ||
| mainElement.Label += string.IsNullOrEmpty(mainElement.Label) ? $"{translation}" : $" - {translation}"; | ||
| } | ||
| if (!string.IsNullOrEmpty(planning.BuildYear)) | ||
| { | ||
| mainElement.Label += string.IsNullOrEmpty(mainElement.Label) ? $"{planning.BuildYear}" : $" - {planning.BuildYear}"; | ||
| } | ||
| if (!string.IsNullOrEmpty(planning.Type)) | ||
| { | ||
| mainElement.Label += string.IsNullOrEmpty(mainElement.Label) ? $"{planning.Type}" : $" - {planning.Type}"; | ||
| } | ||
|
|
||
| if (mainElement.ElementList.Count == 1) | ||
| { | ||
| mainElement.ElementList[0].Label = mainElement.Label; | ||
| } | ||
|
|
||
| mainElement.CheckListFolderName = folderId; | ||
| // EndDate = the rotation date itself. Compare with the handler | ||
| // which uses planning.NextExecutionTime — here we want the | ||
| // deploy bounded to the rotation we're filling. | ||
| mainElement.EndDate = rotationDate; | ||
|
|
||
| // 7. Only call CaseCreate when EndDate is in the future | ||
| // (mirrors ItemCaseCreateHandler.cs:236). Defensive — our | ||
| // `rotationDate >= todayUtc` filter already covers this for | ||
| // same-day rotations, but a clock-skew check costs nothing. | ||
| if (mainElement.EndDate > DateTime.UtcNow) | ||
| { | ||
| var caseId = await sdkCore.CaseCreate( | ||
| mainElement, "", (int)sdkSite.MicrotingUid!, null) | ||
| .ConfigureAwait(false); | ||
|
|
||
| if (caseId != null) | ||
| { | ||
| var caseDto = await sdkCore.CaseLookupMUId((int)caseId).ConfigureAwait(false); | ||
| if (caseDto?.CaseId != null) | ||
| { | ||
| planningCaseSite.MicrotingSdkCaseId = (int)caseDto.CaseId; | ||
| await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 8. Compliance row. Mirrors EformParsedByServerHandler.cs:170-182. | ||
| await EnsureComplianceRowAsync( | ||
| areaRulePlanning, | ||
| planning, | ||
| rotationDate, | ||
| planningCaseSite, | ||
| cancellationToken) | ||
| .ConfigureAwait(false); |
| // EndDate = the rotation date itself. Compare with the handler | ||
| // which uses planning.NextExecutionTime — here we want the | ||
| // deploy bounded to the rotation we're filling. | ||
| mainElement.EndDate = rotationDate; | ||
|
|
||
| // 7. Only call CaseCreate when EndDate is in the future | ||
| // (mirrors ItemCaseCreateHandler.cs:236). Defensive — our | ||
| // `rotationDate >= todayUtc` filter already covers this for | ||
| // same-day rotations, but a clock-skew check costs nothing. | ||
| if (mainElement.EndDate > DateTime.UtcNow) | ||
| { | ||
| var caseId = await sdkCore.CaseCreate( | ||
| mainElement, "", (int)sdkSite.MicrotingUid!, null) | ||
| .ConfigureAwait(false); | ||
|
|
||
| if (caseId != null) | ||
| { | ||
| var caseDto = await sdkCore.CaseLookupMUId((int)caseId).ConfigureAwait(false); | ||
| if (caseDto?.CaseId != null) | ||
| { | ||
| planningCaseSite.MicrotingSdkCaseId = (int)caseDto.CaseId; | ||
| await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false); | ||
| } | ||
| } | ||
| } |
| await eventDeployService.EnsureDeployedAsync( | ||
| request.EjendomId ?? string.Empty, | ||
| request.TavleIds, | ||
| request.FromDateKey ?? string.Empty, | ||
| request.ToDateKey ?? string.Empty, | ||
| sdkSiteId, | ||
| context.CancellationToken); |
| // The handler uses `planning.LastExecutedTime` for StartDate. For an | ||
| // eager deploy that has not actually run yet, LastExecutedTime is the | ||
| // scheduler's previous-rotation marker; fall back to UtcNow when it | ||
| // is null so the StartDate column stays populated. | ||
| var startDate = planning.LastExecutedTime ?? DateTime.UtcNow; | ||
|
|
||
| try | ||
| { | ||
| var compliance = new Compliance | ||
| { | ||
| PropertyId = areaRulePlanning.PropertyId, | ||
| PlanningId = planning.Id, | ||
| AreaId = areaRulePlanning.AreaId, | ||
| Deadline = new DateTime(rotationDate.Year, rotationDate.Month, rotationDate.Day, 0, 0, 0), | ||
| StartDate = startDate, |
| /// <item><c>PlanningCase</c> + <c>PlanningCaseSite</c> rows (mirrors | ||
| /// <c>ItemCaseCreateHandler.cs:83-194</c>).</item> | ||
| /// <item>SDK <c>Case</c> via <c>core.CaseCreate</c> (mirrors | ||
| /// <c>ItemCaseCreateHandler.cs:236-246</c>).</item> | ||
| /// <item><see cref="Compliance"/> row (mirrors | ||
| /// <c>EformParsedByServerHandler.cs:157-184</c>).</item> | ||
| /// </list> | ||
| /// | ||
| /// Idempotence is enforced via the natural <c>(PlanningId, Deadline.Date)</c> | ||
| /// key on <see cref="Compliance"/> and via the canonical | ||
| /// <c>planningCaseSite.MicrotingSdkCaseId >= 1</c> guard for the SDK case | ||
| /// (mirrors <c>ItemCaseCreateHandler.cs:205</c>). |
| public async Task EnsureDeployedAsync( | ||
| string propertyId, | ||
| IReadOnlyCollection<string> boardIds, | ||
| string fromDateKey, | ||
| string toDateKey, | ||
| int sdkSiteId, | ||
| CancellationToken cancellationToken) | ||
| { |
Summary
When flutter-eform fetches the events list for today + 2 days, future-day rotations (today+1, today+2) come back with
compliance_id=0,microting_sdk_case_id=0,fields=[]— because the scheduler chain (SearchListJob.ExecuteDeploy→ItemCaseCreateHandler→EformParsedByServerHandler) only matchesNextExecutionTime <= now. Result: the user can see future events in the list but cannot complete them (CompleteEventhard-errors withFailedPrecondition) and can't upload photos.This PR fixes it without firing any microservice handler. Adds an
IEventDeployServicethat runs the deploy pipeline INLINE inside the plugin's gRPC handler:Compliances.AnyAsync(PlanningId, Deadline.Date).PlanningCase+PlanningCaseSitecreation, mirroringItemCaseCreateHandler.cs:83-89._sdkCore.CaseCreate(...), mirroringItemCaseCreateHandler.cs:238.Compliance.Create(dbContext), mirroringEformParsedByServerHandler.cs:170-182(including the preservedCompliance.PlanningCaseSiteId = planningCaseSite.PlanningCaseIdquirk so the canonical writer's column-name oddity isn't diverged from).Wired into
EventsGrpcService.ListEventsAFTER the permission gate, BEFORE the data fetch — so the sameListEventscall that flutter-eform already makes returns populated Event messages for future rotations.No proto / wire changes. flutter-eform needs zero changes.
Invariants enforced (all checked by pre-push code-reviewer)
IBusinjection).Planning.LastExecutedTime/DoneInPeriod/NextExecutionTime/PushMessageSent— those stay scheduler-owned.CancellationToken.ThrowIfCancellationRequested()at iteration top.Remove/rawDELETE).Plannings,Compliances, etc.) under the schema carve-out.Transparent design omissions vs. canonical
ItemCaseCreateHandlerThese are deliberate — flagged by code-reviewer as correct architectural calls for this slice:
PlanningCaseretraction (handler re-deploys at scheduler tick; eager-deploy is gap-fill — retracting siblings would step on a live rotation).ShowExpireDatedescription-mutation. Worth a follow-up if angular UI parity matters; flutter-eform doesn't render the server-side deadline strap-line.PushMessageBody/PushMessageTitle— aListEventscall must not trigger push to the requesting device.RepeatType.Day && RepeatEvery == 1label-prefix — device formats day labels client-side.Verification
58a933ae).Test plan
dotnet build eFormAPI.slncleanCompliancerow after a singleListEventscall; confirm the user can complete the event and upload a photo🤖 Generated with Claude Code