Skip to content

feat(events): eager-deploy future events inline in ListEvents#813

Merged
renemadsen merged 3 commits into
stablefrom
feat/eager-deploy-future-events
May 14, 2026
Merged

feat(events): eager-deploy future events inline in ListEvents#813
renemadsen merged 3 commits into
stablefrom
feat/eager-deploy-future-events

Conversation

@renemadsen
Copy link
Copy Markdown
Member

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.ExecuteDeployItemCaseCreateHandlerEformParsedByServerHandler) only matches NextExecutionTime <= now. Result: the user can see future events in the list but cannot complete them (CompleteEvent hard-errors with FailedPrecondition) and can't upload photos.

This PR fixes it without firing any microservice handler. Adds an IEventDeployService that runs the deploy pipeline INLINE inside the plugin's gRPC handler:

  • Idempotence guard via Compliances.AnyAsync(PlanningId, Deadline.Date).
  • PlanningCase + PlanningCaseSite creation, mirroring ItemCaseCreateHandler.cs:83-89.
  • _sdkCore.CaseCreate(...), mirroring ItemCaseCreateHandler.cs:238.
  • Compliance.Create(dbContext), mirroring EformParsedByServerHandler.cs:170-182 (including the preserved Compliance.PlanningCaseSiteId = planningCaseSite.PlanningCaseId quirk so the canonical writer's column-name oddity isn't diverged from).

Wired into EventsGrpcService.ListEvents AFTER the permission gate, BEFORE the data fetch — so the same ListEvents call 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)

  • No Rebus publish (no IBus injection).
  • No mutation of Planning.LastExecutedTime / DoneInPeriod / NextExecutionTime / PushMessageSent — those stay scheduler-owned.
  • Per-rotation try/catch; one failure doesn't abort the rest.
  • CancellationToken.ThrowIfCancellationRequested() at iteration top.
  • Soft-delete style preserved (no Remove/raw DELETE).
  • New identifiers are English; inherited Danish entity types (Plannings, Compliances, etc.) under the schema carve-out.

Transparent design omissions vs. canonical ItemCaseCreateHandler

These are deliberate — flagged by code-reviewer as correct architectural calls for this slice:

  1. No sibling PlanningCase retraction (handler re-deploys at scheduler tick; eager-deploy is gap-fill — retracting siblings would step on a live rotation).
  2. No ShowExpireDate description-mutation. Worth a follow-up if angular UI parity matters; flutter-eform doesn't render the server-side deadline strap-line.
  3. No PushMessageBody/PushMessageTitle — a ListEvents call must not trigger push to the requesting device.
  4. No RepeatType.Day && RepeatEvery == 1 label-prefix — device formats day labels client-side.

Verification

  • Pre-push dual-subagent gate: code-reviewer (SHIP) + code-simplifier (2 items applied in 58a933ae).
  • Solution-root build: clean, 0 errors.
  • On-device verification: pending (device temporarily disconnected at the time of push; reviewer to verify post-merge or before merge).

Test plan

  • Pre-push code-reviewer + code-simplifier
  • dotnet build eFormAPI.sln clean
  • On-device smoke: confirm a today+2 rotation gets a Compliance row after a single ListEvents call; confirm the user can complete the event and upload a photo
  • CI green

🤖 Generated with Claude Code

renemadsen and others added 3 commits May 14, 2026 07:30
…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>
Copilot AI review requested due to automatic review settings May 14, 2026 06:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds 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 + EventDeployService that enumerate calendar rotations in the requested window, skip rotations already backed by a Compliance (AnyAsync on (PlanningId, Deadline.Date)), and create the PlanningCase/PlanningCaseSite/SDK case/Compliance chain.
  • Wires the new service into EventsGrpcService.ListEvents after 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 IEventDeployServiceEventDeployService (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:179 by setting Compliance.PlanningCaseSiteId = planningCaseSite.PlanningCaseId (the PlanningCase id, not the PlanningCaseSite id). 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 // TODO referencing 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

Comment on lines +399 to +411
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;
}
Comment on lines +235 to +349
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);
Comment on lines +316 to +340
// 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);
}
}
}
Comment on lines +208 to +214
await eventDeployService.EnsureDeployedAsync(
request.EjendomId ?? string.Empty,
request.TavleIds,
request.FromDateKey ?? string.Empty,
request.ToDateKey ?? string.Empty,
sdkSiteId,
context.CancellationToken);
Comment on lines +374 to +388
// 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,
Comment on lines +28 to +39
/// <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 &gt;= 1</c> guard for the SDK case
/// (mirrors <c>ItemCaseCreateHandler.cs:205</c>).
Comment on lines +59 to +66
public async Task EnsureDeployedAsync(
string propertyId,
IReadOnlyCollection<string> boardIds,
string fromDateKey,
string toDateKey,
int sdkSiteId,
CancellationToken cancellationToken)
{
@renemadsen renemadsen merged commit d9abf12 into stable May 14, 2026
21 checks passed
@renemadsen renemadsen deleted the feat/eager-deploy-future-events branch May 14, 2026 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants