fix(lock): use UTC for the freeze, and stop three fail-open paths and two silent skips - #1715
Merged
Merged
Conversation
… two silent skips
Post-merge audit findings on the "Afstemt" reconciled-day lock.
1. The freeze compares against UTC.
DayLockHelper.CanReconcile used DateTime.Now. PlanRegistration.Date is a
calendar-day label with the time zeroed, not an instant in any timezone, so
there was no local midnight for a local clock to be consistent with. Now
DateTime.UtcNow. CanReconcile is plugin-only, so no twin drift with the
service repo.
2. Three fail-open null branches now refuse or skip.
A rule whose whole purpose is "this day cannot be written" must not waive
itself when the site id is unknown.
- RefuseIfNotWritableAsync (renamed from CheckDayLockAsync, since it now
refuses on two grounds and only one is a day lock): a null site id returned
null, which is this method's "proceed" answer. It now refuses. The kiosk
overload has no null guard of its own and dereferences the same id with `!`
further down, so a null used to end as a raw InvalidOperationException.
- Import: a sheet whose site has no MicrotingUid produced a null boundary,
which makes IsLocked false for EVERY row, importing the whole sheet with no
lock check. It is now skipped, counted and reported.
- GoogleSheetHelper: the lock line's null check was provably dead, and had it
fired it would have processed the row with no lock check while three
neighbouring lines throw. It now matches its neighbours.
3. Swallowed exception detail restored, at both sites in PlanRegistrationHelper.
The try blocks contain planRegistration.Update calls, so DbUpdateException,
DbUpdateConcurrencyException and MySqlException were all reported as the
string "Could not parse PlanText" while the type and stack went to Trace.
Both sites now capture the exception itself, once per channel.
4. The xlsx import no longer reports plain success while dropping rows.
Locked days and unresolvable sheets are counted and named in the result, the
way ReconcileThrough already distinguishes its skip reasons. The counts are
also logged server-side in a finally, so they survive the exception path, and
an unresolvable sheet raises a Sentry warning.
5. UpdateAssignedSite needs no guard, and now says so.
It only touches Date >= today (UTC midnight), while a locked day is always
strictly in the past: Reconciled has one writer, and the two callers that set
it true both gate on CanReconcile. The ranges are disjoint. Comment only.
Also in this change, beyond the five fixes:
- PlanRegistrationHelper's second Sentry site changed from `catch (Exception)`
to `catch (Exception e) when (e is not DayLockedException)`. That is a
CONTROL-FLOW change, not just a logging one. The net outcome is unchanged:
the rejected entry stays tracked either way, so the sole caller's own Update
at TimePlanningWorkingHoursService.cs:720 rethrows whether or not this catch
swallowed it. Without the filter, uncommenting the capture would have started
reporting routine lock refusals to Sentry as incidents.
- Adding a SiteNotFound entry to Translations.resx and Translations.da.resx
changes user-visible text at SEVEN pre-existing call sites, not just the new
one: TimePlanningWorkingHoursService.cs:1231, 1440 and
TimePlanningPlanningService.cs:507, 519, 530, 1209, 1221. The key was already
in use but had no resource entry, so those flows rendered the raw string
"SiteNotFound". They will now say "Medarbejder ikke fundet.", including in
flows unrelated to the lock.
- The import's failure path used to show a GREEN success toast for a
server-reported failure. The endpoint returns OperationResult with HTTP 200
regardless of its Success flag, so ng2-file-upload routes failures to
onSuccessItem. The upload modal now branches on the body rather than the
transport, and shows the server's own diagnosis as an error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There is at least one correctness issue in the updated code path (UTC midnight derivation built from multiple UtcNow reads) that can yield an incorrect day boundary at runtime.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR hardens the “Afstemt” (reconciled-day) lock and import pipeline by making the lock’s “freeze” comparison UTC-based, eliminating several fail-open/null-path behaviors, restoring Sentry exception detail, and surfacing partial-import outcomes to users via the upload modal.
Changes:
- Switch lock eligibility (
CanReconcile) toDateTime.UtcNowand align/clarify related lock logic and comments. - Prevent fail-open writes/imports when a site/worker cannot be resolved; report skipped sheets/rows and log counters reliably.
- Improve observability (capture real exceptions to Sentry) and update the Angular upload modal to respect
OperationResult.successand display server messages.
File summaries
| File | Description |
|---|---|
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs | Renames and hardens write-guard; import now counts/reports skips, logs summary in finally, and captures Sentry warnings. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs | Refactors UpdateAssignedSite planning-fill logic and documents why it doesn’t need a day-lock guard. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs | Clarifies ReconciledAt as display/audit time and intentionally leaves it as DateTime.Now. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx | Adds new localized strings for site-not-found and import summaries (default). |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs | Regenerates strongly-typed resource accessors for new translation keys. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx | Adds Danish translations for the new keys. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs | Restores Sentry exception capture and improves error logging semantics while filtering out DayLockedException. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs | Removes dead null-tolerant lock-check and makes the lock check strict. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs | Updates CanReconcile to use UTC and expands rationale in comments. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs | Adds assertions for “no skips” message and new coverage for unresolvable-sheet skip/report behavior. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs | Aligns date-boundary tests with UTC behavior and adds kiosk null-site refusal coverage. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs | Updates reconciliation boundary tests to use UTC consistently. |
| eform-client/src/app/plugins/modules/time-planning-pn/modules/working-hours/components/working-hours-actions/working-hours-upload-modal/working-hours-upload-modal.component.ts | Parses OperationResult from HTTP 200 responses; shows server message and treats success=false as an error. |
Review details
Files not reviewed (1)
- eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs: Generated file
- Files reviewed: 12/13 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // order days were reconciled in. Both sides compare against UtcNow.Date, | ||
| // which makes the two ranges disjoint: no write from here can reach a | ||
| // locked row. | ||
| var midnight = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day, 0, 0, 0); |
Comment on lines
4249
to
4251
| SentrySdk.CaptureException(ex); | ||
| logger.LogError(ex.Message); | ||
| return new OperationResult(false, ex.Message); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Five correctness fixes found by a post-merge audit of the "Afstemt" reconciled-day-lock feature, plus the UI wiring needed to make one of them actually reach a user.
1. The freeze compares against UTC
DayLockHelper.CanReconcileusedDateTime.Now. The comment justified it as "PlanRegistration.Date is a local midnight" — that premise is wrong.PlanRegistration.Dateis a calendar-day label with the time zeroed, not an instant in any timezone, so there is no local midnight for a local clock to be consistent with. Writers do not even agree on how they build the label (TimePlanningFlexServicederives one fromDateTime.Now), which is a reason to pin this comparison to one clock rather than follow theirs.Now
DateTime.UtcNow. The rewritten comment also states the direction honestly rather than as a law: at a positive offset (CET/CEST, where this product runs) UTC is the conservative choice for a freeze; at a negative offset the same expression would be the permissive one and would break I2. That case does not arise here, and the answer if it ever does is an explicit business timezone.CanReconcileis plugin-only — the service repo's twinDayLockHelperomits it and has noDateTime.Now— so this creates no twin drift. No blanket sweep: the otherDateTime.Nowuses in this repo are unrelated to the lock and were left, includingReconciledAt, which is a display instant rather than a comparison input.2. Three fail-open null branches
A rule whose entire purpose is "this day cannot be written" must not waive itself when it cannot resolve the site.
RefuseIfNotWritableAsync(renamed fromCheckDayLockAsync, because it now refuses on two grounds and only one of them is a day lock — and to disambiguate it from the same-named method inTimePlanningPlanningService): a null site id returnednull, which is this method's "proceed" answer, so a missing site id permitted the write. It now refuses with a localized message. The kiosk overload has no null guard of its own and dereferences the same id with!a few hundred lines later, so a null used to sail past the lock and then throw a rawInvalidOperationException.Import: a sheet whose site has noMicrotingUidproduced a null boundary, and a null boundary makesIsLockedfalse for every row — so an unresolvable site imported a whole sheet with no lock check at all. It is now skipped, counted and reported.GoogleSheetHelper: the null check on the lock line was provably dead (sitesByKeyfiltersMicrotingUid != nullandworkersis built only from it), but had it ever fired it would have been the one line in the loop that degrades silently while its neighbours throw. It now matches them.3. Swallowed exception detail restored
PlanRegistrationHelperhad//SentrySdk.CaptureException(e);commented out at two sites. Those try blocks containplanRegistration.Updatecalls, soDbUpdateException,DbUpdateConcurrencyExceptionandMySqlExceptionwere all reported to Sentry as the string "Could not parse PlanText" while the actual type and stack went toLogTrace. Both sites now capture the exception itself, once per channel, and the first logs at Error with a structured message instead of guessing.4. The xlsx import no longer reports plain success while dropping rows
Locked days were skipped silently and the count went only to
Console.WriteLine. Someone importing a corrected timesheet over a reconciled month saw "success", saw the old numbers, and re-imported forever.The result now names each skip with its reason, the way
ReconcileThroughalready distinguishes its own. Two counters, composed as whole sentences so they join in any language without having to agree grammatically. The counts are also logged server-side in afinally, so they survive the exception path and the malformed-file early returns — that is the channel ops actually has, and it replaces theConsole.WriteLinethis change removes. An unresolvable sheet additionally raises a Sentry warning, matching whatGoogleSheetHelperalready does for its sheet problems.The UI had to be wired for any of this to be visible:
onSuccessItemignored the response body entirely and toasted a hardcoded string. It now renders the server's message, falling back to the existing translated string.5.
UpdateAssignedSite— verified, documented, no guard addedFlagged by the audit as an unguarded write path. It is not one. It only reads, creates and writes rows with
Date >= today(UTC midnight), while a locked day is always strictly in the past:Reconciledhas exactly one writer, and the two callers that set it true both gate onCanReconcile. The ranges are disjoint. Comment only, so the next auditor does not re-raise it — and note that fix 1 is what makes it provable, since underDateTime.Nowa server at a positive offset late in the UTC day could accept a day equal toUtcNow.Date.Testing
C# — new and updated cases in three fixtures that are already in the shard filters, so no workflow change was needed:
ReconcileServiceTests: newUpdateWorkingHour_Kiosk_WithNoSiteId_IsRefused_AndWritesNothing(fix 2a). Pre-fix this threw a rawInvalidOperationException.WorkingHoursImportRemovedRowTests: newImport_ASheetWhoseWorkerHasNoMicrotingUid_IsSkippedAndReported(fixes 2b and 4), plus assertions on the reported skip count and on the zero-skip path.DayLockHelperTestsand twoReconcileServiceTestscases updated fromNowtoUtcNow. These are corrections, not weakened assertions: left onDateTime.Now, the boundary cases fail on a dev machine at either a positive or a negative offset.TypeScript — never compiled locally. This repo carries only the plugin module, with no
node_modulesand no host app, sotsc/ng buildcannot run here. CI is the first compile of the upload-modal change.No e2e coverage of the upload flow exists at all — not before this change and not after. The new failure branch is therefore untested end to end. Flagging rather than implying otherwise.
Behaviour notes
TZ— this repo's Dockerfile sets none and installs no tzdata, and themcr.microsoft.com/dotnet/aspnetbase image defaults to UTC. It only makes dev machines behave like the container.SiteNotFoundresx entry has a blast radius beyond this feature. The key was already in use but had no resource entry, so it rendered as the raw stringSiteNotFound. Adding it changes user-visible text at seven pre-existing call sites (TimePlanningWorkingHoursService.cs:1231, 1440;TimePlanningPlanningService.cs:507, 519, 530, 1209, 1221). "Medarbejder ikke fundet." will now appear in flows unrelated to the lock. An improvement, but nobody should be surprised by it.catch (Exception)becamecatch (Exception e) when (e is not DayLockedException). The net outcome is unchanged — the rejected entry stays tracked either way, so the sole caller's ownUpdateatTimePlanningWorkingHoursService.cs:720rethrows whether or not this catch swallowed it. Without the filter, uncommenting the capture would have started reporting routine lock refusals to Sentry as incidents.OperationResultwith HTTP 200 regardless of itsSuccessflag, so ng2-file-upload routes server-reported failures toonSuccessItem. The modal now branches on the body rather than the transport.Follow-ups recorded and deliberately not fixed here
GoogleSheetHelper.cs:288-292still has a null-tolerantMicrotingUidcheck whose comment says such a site "cannot be resolved; skip it here" — the same pattern the new comment at:359-364argues against. Not a bug (it guards the one-minute timeline dictionary, not the lock), but the file now reasons in opposite directions about the same property.unresolvableSheetsSkippedcounts sheets skipped, not rows lost, so it overstates the loss for a wholly empty tab named after such a worker.OperationDataResultwith a typed model — the wayReconcileThroughdoes — would let the UI choose toast severity properly. Today the success toast stays green even when it carries skips, because the only signal the component receives is a localized string and sniffing it for skip text would break on the first copy edit or non-Danish locale.🤖 Generated with Claude Code