Skip to content

feat(events): Phase 2 — rename gRPC tavle types to board#803

Merged
renemadsen merged 169 commits into
masterfrom
feat/tavle-to-board-rename
May 12, 2026
Merged

feat(events): Phase 2 — rename gRPC tavle types to board#803
renemadsen merged 169 commits into
masterfrom
feat/tavle-to-board-rename

Conversation

@renemadsen
Copy link
Copy Markdown
Member

Summary

Phase 2 of the opgave→event migration: rename the gRPC message types and RPC method names from Tavle to Board on the plugin side. Mirrors the Phase 1 pattern (PR #802 8703e4ba) — wire field names stay so generated C# accessors don't churn.

Spec: docs/superpowers/specs/2026-05-11-opgave-to-event-migration.md (Phase 2 subsection).

Renamed (gRPC type / RPC method)

  • rpc ListTavler(ListTavlerRequest) returns (ListTavlerResponse)rpc ListBoards(ListBoardsRequest) returns (ListBoardsResponse)
  • message Tavlemessage Board
  • message ListTavlerRequest/ResponseListBoardsRequest/Response
  • EventsGrpcService.ListTavler(...) handler → ListBoards(...)
  • Top-of-file proto comment updated to document Phase 2 boundaries

Stayed Danish (CARVE_WIRE_FIELD)

  • Wire field names: tavler (the repeated field on ListBoardsResponse), tavle_id, tavle_ids, id, ejendom_id, name, color_hex
  • 15 generated C# accessors (request.TavleIds reads, event.TavleId = ... setters) untouched

Verification

  • dotnet clean && dotnet build from eFormAPI.Web/ — 0 errors (10 pre-existing warnings)
  • Backend restarted, Kestrel binds :5000 + :5001 cleanly, no plugin-load exceptions
  • Embedded copy at eform-angular-frontend/eFormAPI/Plugins/BackendConfiguration.Pn/ synced (diff -q silent)
  • Dual-subagent gate (code-reviewer + code-simplifier) green after one fixup round (proto top-of-file comment collapsed from ~14 to 6 lines per simplifier)

Test plan

  • Coordinated with Flutter PR (microting/flutter-eform#TBD) so the renamed gRPC surface is live before the Flutter app that calls it
  • On-device smoke test: cold-start → bootstrap shows boards + properties; refresh propagates board edits

🤖 Generated with Claude Code

renemadsen and others added 30 commits April 18, 2026 05:08
When clicking events near the top of the scrolled grid, slotTop could
be very small or negative (getBoundingClientRect on a scrolled-away
column). The overlay's close button was cut off above the viewport.

Fix: clampAnchorY() ensures the anchor is always within 8px-margin of
the viewport edges. Also added withFlexibleDimensions(true) to let CDK
resize the overlay pane to fit within the viewport.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CDK's withPush/withFlexibleDimensions don't reliably prevent overlays
from extending beyond the viewport, especially when using virtual
anchor points. Added ensureOverlayInViewport() which runs after each
overlay attach — measures the actual rendered bounding rect and applies
a translateY correction if the top or bottom overflows.

Applied to all 4 overlay attachment sites (3 create + 1 preview).

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

The previous setTimeout ran before Angular finished rendering. Now uses
double requestAnimationFrame to ensure paint is complete, then:
1. Sets pane maxHeight to viewport - 16px (constrains content)
2. Measures actual rect after constraint
3. Shifts pane with translateY if top or bottom overflows

This guarantees both close button and save button are always visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous approach applied translateY via transform, which CDK
overwrites on any reflow (e.g. async eform template loading).
Set pane.style.top directly and re-run on ResizeObserver events
so content growth after initial attach still keeps pane in viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mapRepeatType returned legacy values ('weekly', 'monthly', 'yearly')
that the edit dropdown never emits, leaving Gentag empty on existing
tasks. Align the CalendarRepeatRule union, mapRepeatType, and the
preview-modal label map with the dropdown's values
('weeklyOne', 'weeklyAll', 'monthlyDom', 'yearlyOne').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Added 8 missing keys (Custom repeat, Repeat on, Ends, Never, On, After,
occurrences, Done) so the calendar's custom-repeat modal renders in the
user's language instead of falling back to English.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The create/update save subscribe silently ignored OperationResult with
success=false, leaving the modal open with no feedback when preconditions
(e.g. missing Logbøger folder for a fresh property, missing report
headline) failed on the backend. Use ngx-toastr to show the server
message, plus i18n keys 'Could not save the event' and 'Error'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new CI matrix slot 'm' that creates a property "Den glade gris
<suffix>" + worker "foo bar" assigned to it, then saves a calendar event.
Asserts the POST to /calendar/tasks returns success=true. When the
reported silent-failure bug is present the test fails with the exact
backend message (e.g. missing Logbøger folder), pinpointing which
precondition needs fixing rather than guessing.

Extends both dotnet-core-master.yml and dotnet-core-pr.yml test matrix
from [a..l] to [a..l,m].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous version used today's weekday and hour 10, but the calendar
grid rejects clicks on past time slots. When CI ran late in the day
(after 10:00 server time), the click was silently ignored and the
create modal never opened, timing out at 600s. Navigate one week forward
so every day is in the future, then click Monday 10:00 which is always
safe. Also wait for #calendarEventTitle to appear before filling, so a
genuine modal-open failure reports immediately instead of after 10 min.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CreateTask silently failed ("FolderIsRequired") on fresh properties
whose Logbøger area/folder wasn't provisioned during property creation.
Rewrites ResolveOrCreateLogbøgerFolderAsync to seed the Logbøger Area
(and its translations) from BackendConfigurationSeedAreas if missing,
then inlines the AreaProperty/Folder/ProperyAreaFolder/AreaRule
creation portion of BackendConfigurationPropertyAreasServiceHelper.Update
so we don't trigger its assignmentsForDelete side effect that would
destroy other active AreaProperties for the property.

Also: IsDisabled guard on seed lookup, LogError on missing seed,
FirstOrDefaultAsync on CheckListTranslations so missing eForms on
minimally-seeded DBs don't blow up rule creation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matrix l's calendar copy test failed consistently because
verifyEventExists() called .isVisible() synchronously, racing the
post-property-selection calendar re-render. Renamed to waitForEvent()
which awaits .waitFor({state:'visible'}) and returns false only on
timeout — call sites now include the missing title in the assertion
message for faster triage.

Matrix c's task-wizard.delete test failed intermittently because
several waitForTimeout(500) gaps between overlay interactions were
too tight for slow CI. Replaced every fixed sleep with explicit
visibility/hidden/count assertions, extracted the "open action menu,
click delete" sequence into a helper, gated the final row-disappear
assertion on the DELETE API response, and added dropdown-panel
count-0 waits between sequential ng-select interactions so the
second panel's visibility check doesn't match the first panel's
closing frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The worker create/edit modal's save button was gated on form.invalid,
which stays true until the async language fetch populates the dropdown
and patches the default languageCode. E2e tests polled the disabled
state with a 30s timeout and failed intermittently when the async init
lost the race — a silent click on an empty ng-select left the form
permanently invalid.

Add a formReady boolean set to true inside the getEnabledLanguages tap
(on every emission, even API failure, so a broken backend surfaces as
a clear missing-field assertion rather than a cryptic readiness
timeout). Expose it on the root form as data-form-ready so tests can
wait deterministically. Move the getEnabledLanguages call up near the
top of ngOnInit (after form construction, safe against synchronously-
replayed store values) so the HTTP request fires sooner.

In the page object, after opening the create modal wait for
data-form-ready=true before filling any field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c: Removed an incorrect toContainText(bindArea) assertion on the mat-
select trigger after selecting a folder leaf — the trigger text does
not reflect the selected folder in this form, so the assertion always
timed out. The subsequent test steps succeed without it.

l: The copy-event test asserted event visibility after only waiting on
the folder-dtos response, but the events themselves are fetched by a
separate POST /calendar/tasks/week. Added a waitForResponse for that
request before asserting the event renders, so the 10s waitForEvent
window starts from a known-complete fetch rather than racing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous formReady only waited for languages to load. Tests still hit
flaky #saveCreateBtn disabled timeouts because the valueChanges
cascades on enableMobileAccess/webAccessEnabled/archiveEnabled each
re-ran updateEmailValidation() synchronously, leaving form.invalid
briefly true through several microtasks.

Replace the one-shot languages flag with a two-phase gate:
  1. languagesLoaded$ (ReplaySubject<void>(1)) emits once getLanguages
     returns, whether success or failure.
  2. A formReadyWatcher$ subscription waits for that emission, then
     subscribes to form.statusChanges with startWith(form.status),
     debounceTime(200), filter(status !== 'PENDING'), first() —
     flipping formReady exactly once when the form reaches a steady
     non-pending state.

Also wrap the three email-toggle valueChanges subs with
debounceTime(50) so rapid programmatic fill() sequences coalesce
instead of each pushing the form through PENDING.

data-form-ready='true' now means "every synchronous init cascade has
settled", so e2e tests can fill and save without racing the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The copy-event test still times out at 10s even after waiting on
/calendar/tasks/week response. Two changes to diagnose + cover the
simpler causes:

- Log the week-tasks payload count/titles after the response arrives
  so we can see whether the event is actually in the backend data
  (rules out test 1 silently failing to persist) vs. pure UI render
  delay.
- Bump the waitForEvent timeout from 10s → 30s. On slow CI runners
  the full calendar re-render after a fresh /tasks/week fetch may
  exceed 10s while still being under a sane upper bound.

If the log shows the event is in the payload but it still times out
at 30s, we have a real rendering bug to chase. If the log shows the
event is missing, test 1 has a silent save-persistence issue.

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

The previous run showed week-tasks count=0 after test 1 successfully
created the event. Expand diagnostics to distinguish three hypotheses:

1. Wrong captured request — Playwright's waitForResponse may have
   matched an early pre-selection /tasks/week call.
2. UI filter excluding the event — activeBoardIds or other filter
   narrowing the visible set.
3. Silent persistence failure — test 1's success=true was misleading.

E1: Log captured request URL + postData + full response body, plus
    the parsed count/titles.
E2: From within the page context, fetch /tasks/week directly with
    the property from the captured request and WIDE-OPEN filters
    (empty boardIds/tagNames/siteIds). Log the result.

If E1 reveals a stale capture (propertyId missing/zero), we fix the
predicate. If E2 returns the event but the captured UI response
didn't, it's a filter bug. If E2 also returns 0, persistence is
the real issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a calendar event saved with no assignees was accepted
(success=true) but then hidden by GetTasksForWeek's x.Status filter
(task-wizard downgrades Status to NotActive when Sites is empty).
Net effect: users created events that immediately vanished from the
calendar. The diagnostic run confirmed this with count=0 payload.

Enforce "at least one assignee" in three places:

1. Backend (BackendConfigurationCalendarService.CreateTask + UpdateTask):
   return OperationResult(false, "AtLeastOneWorkerMustBeAssigned")
   when Sites is null or empty. New i18n key added across all 26
   supported locales in Resources/localization.json.

2. Frontend (task-create-edit-modal.component.html): the Save/Gem
   button [disabled] also gates on assigneeControl.value.length > 0.
   New plugin i18n key 'At least one worker must be assigned' used
   for the button's tooltip. Auto-translate script propagated to all
   non-English locales.

3. Tests: l/calendar.spec.ts now creates a worker assigned to the
   test property before saving events (mirrors m's pattern), and
   l/calendar.page.ts's fillCreateModal selects the first available
   assignee (new id=calendarEventAssignee on the mtx-select). Dropped
   the E1/E2 diagnostic block — we have the answer. afterAll now
   clears workers before properties.

Task-wizard code is unchanged per user directive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Assignee requirement is now enforced; test 1 creates the event with
status=200 success=true, but test 2's waitForEvent still times out.
With Status=Active guaranteed, the event should be in the /tasks/week
payload. Log the captured request body, full response (first 1000
chars), and a direct-fetch result with wide-open filters to pin down
whether the event is in the payload, filtered out, or missing
entirely. Remove after the root cause is confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Screenshot from the diagnostic run showed the event IS rendered on
the calendar — so the locator 'app-calendar-task-block' (the Angular
component tag) wasn't matching. Switch to .task-block (the actual
rendered inner class, same selector I verified by hand earlier).

Remove the E1+E2 diagnostic logging now that the root cause is
known: the backend payload contains the event, the DOM contains the
event, only the test selector was wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
l copy-event assertion used toContain('Copy of') but the admin user's
locale is Danish ('da'), so the modal shows 'Kopi af Event-xxx'.
Switch to asserting the original title is still contained plus a
length-greater check — locale-agnostic and still verifies the copy
semantics (prefixed with SOMETHING, original title preserved).

afterAll cleanups were failing on worker-delete action-menu
interactions and aborting the whole suite AFTER the actual tests had
passed. Wrap clearTable calls in .catch so cleanup issues log as
non-fatal warnings instead of failing the matrix slot. Test matrices
get ephemeral DBs per job so leftover rows don't contaminate
anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
l: the post-copy visibility assertion still hardcoded "Copy of",
which fails in Danish locale ("Kopi af"). The copy save itself
passes (status=200, success=true, planning tag + eform preserved).
Switch the assertion to wait for any event containing the original
title — the locale-specific prefix is tested separately at the modal
title step.

l + m afterAll: wrap cleanup in Promise.race with a 60s cap so a
hung action-menu interaction in worker-delete never blocks the suite.
Any error inside cleanup is caught and logged as non-fatal. Each
matrix slot runs against an ephemeral DB so leftover rows can't
contaminate other jobs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After saveModal in the copy flow the modal closes, loadTasks
refetches /calendar/tasks/week, and the grid re-renders — more
ground to cover than the initial render. The post-copy visibility
check was hitting the 10s default right as the re-render completed.
Bump to 20s, same headroom the earlier check had before cleanup.

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

- Extend calendar.proto CalendarTaskItem with eform_id (21) and sdk_case_id (22)
- Add SdkCaseId property to CalendarTaskResponseModel
- Map EformId and SdkCaseId in CalendarGrpcService
- Map compliance.MicrotingSdkCaseId in calendar service for compliance tasks

Enables the Flutter app to call ReadComplianceCase with the SDK case ID
to render real eForm content in task detail pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When CI runs after the hour the test clicks (e.g. run at 10:31,
click-target 10:00), the calendar's onCellClick rejects the past-time
click and the modal never opens — locator.fill on
#calendarEventTitle then blocks until the 10-min test timeout.

Apply the same fix already used in m/: navigate one week forward
with the chevron_right nav, then click Monday 10:00. All 7 days are
in the future by construction so the fill can't race the wall clock.

Test 2 navigates forward too so test 1's event (now in next week)
is visible, waiting for the corresponding tasks/week POST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a copy operation two task-blocks contain the same title
substring (the original and the 'Kopi af …' copy). Playwright's
strict-mode default rejects locators with multiple matches, so the
waitFor throws silently (returned false via our try/catch, looked
like a plain timeout).

Use .first() so the wait only cares that AT LEAST ONE matching
event is visible — sufficient for our assertion (either the copy or
the original must be rendered, and after save both exist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add eform_id and sdk_case_id to calendar task proto
Two bugs on the event-modal date input:

1. Displayed '4/21/2026' (US short) instead of 'Mandag, 21. april'
   (weekday, day, month in Danish).
2. Clicking the input opened an empty mini calendar — month grid
   rendered with no day numbers.

Both symptoms traced to MatNativeDateModule being imported in
CalendarModule: it shadows the app-global EformMatDateFnsDateModule
adapter, so the datepicker used the native adapter (no locale awareness
→ empty grid, US format).

Fix:
- Drop MatNativeDateModule from CalendarModule imports; rely on the
  global date-fns adapter that AuthStateService already drives with
  the user's locale (customDaLocale for Danish).
- Add a module-scoped MAT_DATE_FORMATS provider overriding
  display.dateInput to 'EEEE, d. MMMM'. Keep parse.dateInput as 'P'
  so manual typing still accepts short dates. Scope is module-local
  so other plugins' datepickers keep the global short format.

With customDaLocale loaded, EEEE → 'Mandag', MMMM → 'april', producing
'Mandag, 21. april'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously clicking an empty slot snapped to the nearest 15-minute
position, which was too fine-grained — a click slightly above 10:00
could land on 9:45 or 10:15 unpredictably. Switch the week-grid and
day-column click handlers to round to the nearest 30 minutes.

Drag/resize handlers keep the 15-minute grid (users who explicitly
pick up an event often want finer control), and the edit modal's
start/stop time inputs remain unconstrained — users can still set
any time when editing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
onNavigate shifted by 7 days regardless of view mode, which is wrong
for day and list/schedule views — users expect one-day steps there.
Gate the shift amount on viewMode: 7 for 'week', 1 for 'day' and
'schedule'.

When switching FROM week view TO day or list view the user should
land on today, not on an arbitrary day of whichever week they were
viewing. Reset currentDate to today on that transition.
onViewModeChange also now calls loadTasks so the freshly-scoped range
is fetched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renemadsen and others added 28 commits May 7, 2026 17:03
The angular GET /api/backend-configuration-pn/compliances/cases?id&templateId
returns a ReplyElement built by SqlController.CheckRead, which materialises
every Field's FieldValue and runs them through ReadFieldValue. For Number
and NumberStepper fields, ReadFieldValue rewrites NULL → "" before JSON
serialisation (eform-sdk SqlController.cs lines 2217-2231) — Date does the
same (lines 2233-2247) but the round-trip parser rejects "" so it never
emits a write pair. Other types keep NULL on the wire.

Angular's PUT then runs CaseUpdateHelper.GetFieldValuesByRequestField
(eFormApi.BasePn CaseUpdateHelper.cs lines 95-103). It emits a
"[fieldValueId]|" pair for every Number whose Value is non-null on the
wire — which after the GET-case rewrite includes every NULL Number
FieldValue. Core.CaseUpdate (Core.cs lines 1649-1654) then writes "" to
those FieldValues and PnBase.Update emits a Version row.

Mobile's CompleteOpgave was skipping FieldValues entirely on
empty-complete, so the parity-harness s3 scenario consistently showed:
  - 420_SDK.FieldValueVersions: row only in ANGULAR
  - 420_SDK.FieldValues pk=N: Value angular="" mobile=null

The previous commit (reverted in 45b0dc4) tried to fix this by writing ""
to ALL NULL FieldValues for the case, which over-fired and clobbered
non-Number FVs that angular deliberately leaves untouched (regressing
s2/s5 in the harness).

This change mirrors the EXACT canonical filter:
  - Field is in the case's CheckList tree (Field.WorkflowState != Removed,
    Field.CheckListId IN (case CheckList ∪ subtree CheckLists))
  - FieldValue.CaseId == foundCase.Id
  - FieldValue.Value IS NULL
  - FieldValue.WorkflowState != Removed
  - Field's FieldType is Number or NumberStepper

Verified by parity-harness s2 / s3 / s5: all three scenarios produce
byte-identical DB deltas across angular and mobile.

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

Parity harness s_photo_upload_delete caught: mobile's UploadPhoto wrote
only to UploadedDatas + Cases.Custom JSON, while angular's AddNewImage
wrote to UploadedDatas + FieldValues. Net effect: a photo uploaded via
mobile was invisible to the angular admin (and vice versa) because the
read paths look at different storage.

Now mirrors angular's FieldValues write while keeping Cases.Custom for
the existing mobile read path (backward compat). Extension format
normalized to no-leading-dot ("png" not ".png") matching angular's
FileName.Split(".").Last() shape. FileName now follows angular's
two-phase Create-then-Update rename, and FileLocation is populated with
the same intermediate-path shape (Path.GetTempPath()/cases-temp-files
ticks) that angular writes — column metadata only; mobile remains
S3-only for the actual bytes.

Discovers the Picture-typed FieldId by walking the case's CheckList
descendant tree (BFS), matching the harness picker and the angular UI
which passes the fieldId from the rendered template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lback can find retracted cases

Two related defects surfaced during a live device test of compliance
9810 / case 17701 (a retracted missed-deadline rotation that completes
correctly in the angular admin but failed from the mobile app):

1. ListOpgaver's ActionableOnly mode strips retracted compliances from
   compliancesInWeek but the recurrence loop re-emits the same logical
   row with ComplianceId=null. Device cached compliance_id=0 in Drift,
   so subsequent writes went out without IDs and hit the fallback.
   Fix: side-dict from the stripped compliances, populate ComplianceId
   + SdkCaseId on the recurrence-emit model.

2. The fallback fuzzy lookup in 4 write handlers excluded retracted
   cases (WorkflowState != Removed on validCaseIdsForSite), so any
   payload with compliance_id=0 could never resolve a missed-deadline
   compliance — even though the success path can revive the case.
   Fix: drop the Cases.WorkflowState filter; let the fallback find
   retracted cases. The PK branch and success path already handle
   revival correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CompleteOpgaveRequest now carries `repeated FieldValueWrite field_values`
and `string comment`. Server applies the bundle AFTER case revival
(WorkflowState='created') and BEFORE the closure cascade
(PlanningCase/PlanningCaseSite Status=100 + core.CaseDelete soft-delete) —
same lifecycle window the angular admin path uses
(BackendConfigurationCompliancesService.Update lines 223-260).

Bundle apply reuses the SetFieldValue handler's helpers verbatim:
(caseId, fieldId) → FieldValues.Id lookup, CanonicalizeFieldValueAsync
for CheckBox/Select normalization, single batched core.CaseUpdate +
core.CaseUpdateFieldValues. Skips field_id <= 0 and missing FieldValue
rows so legacy-cached fields don't fail the whole bundle.

Comment apply mirrors SetComment's envelope-write shape — non-empty
replaces OpgaverComment body verbatim; empty string is treated as "no
change" so legacy clients pass through unchanged. Per-RPC SetFieldValue
and SetComment handlers remain in place for legacy outbox rows still
draining from older app builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile's atomic-save bundle silently dropped writes when FieldValues
rows hadn't been materialized for the case (case never read via
GET /compliances/cases on the admin browser side). Angular's PUT runs
after a GET that materializes via Core.CaseRead; mobile's
ListTaskTracker doesn't trigger that path.

CompleteOpgave now calls core.CaseRead before the per-field lookup,
matching angular's lazy-materialization mechanism. Field values
typed on the device now reach the SDK and surface in reports.

Hotfix on top of the atomic-save commit (3a4c4db). Full
convergence to angular's BackendConfigurationCompliancesService.Update
tracked as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile's CompleteOpgave was stamping DoneAt with the server's wall
clock (DateTime.UtcNow / request.ClientTsUnix), which meant a worker
completing a missed-deadline rotation produced reports dated today
rather than the rotation's actual scheduled deadline.

Now derives doneAtUtc = compliance.Deadline (with a != default fallback
to DateTime.UtcNow for legacy / partially-populated rows, mirroring the
existing pattern at lines 1681 / 1938 / 2734) once at the top of the
closure and propagates to Case.DoneAt / DoneAtUserModifiable,
PlanningCase.MicrotingSdkCaseDoneAt, PlanningCaseSite.MicrotingSdkCaseDoneAt,
and the dayKey used for the post-completion calendar refresh.

The bundled comment write keeps wall-clock semantics via a separate
commentAtUtc local — the comment TsUnix tracks when the worker actually
authored the comment on the device, which is genuinely distinct from
when the rotation was scheduled (Deadline).

Core.CaseUpdate / CaseUpdateFieldValues do NOT accept a doneAt parameter
(SqlController.FieldValueUpdate writes Value only, no DoneAt stamp), so
no changes are needed for FieldValue rows — they don't carry a DoneAt
in this code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Crash at report-table.component.ts:129 when caseField.value is null
/ undefined / empty for number+date columns: TypeError on
value.replace and RangeError from parseISO(invalid). Mobile-completed
cases sometimes carry empty FieldValues (per SDK convention) which
the formatter wasn't guarding against.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements PR-3 of the Google Drive integration: customer-side
endpoints and services for receiving the OAuth envelope from the
proxy, refreshing tokens via the proxy, and downloading attached
Drive files into S3 via the existing UploadedData pipeline.

- New endpoints under /api/backend-configuration-pn/google-drive:
  /start (build proxy URL with HMAC + nonce cookie), /oauth-finish
  (verify envelope JWT typ/nonce/user, persist GoogleOAuthToken
  with AES-GCM encrypted refresh token), /status (check connection),
  /attach (download Drive file and create AreaRulePlanningFile).
- Services: GoogleDriveAuthService (envelope verification, AES-GCM
  at-rest crypto with zeroed-on-finally buffers, IMemoryCache for
  access tokens with proxy-supplied expiry), GoogleDriveFileService
  (Drive download mirroring UploadFile pattern; mime + size guards),
  OAuthProxyClient (typed HttpClient with HMAC-signed /refresh).
- Nonce CSRF defense via IDataProtectionProvider-signed cookie
  bound to userId, single-use, 5-min lifetime, X-Forwarded-Proto
  aware Secure flag.
- Strongly-typed GoogleDriveOptions with [Required, Url] and
  MinLength(32) annotations on the two key fields.
- 4 new localization keys in 8 languages, plus 3 keys that were
  already referenced from BackendConfigurationCalendarService.
- 8 integration tests using Testcontainers MariaDB + WireMock for
  proxy and Drive surfaces. WebViewLink is synthesized client-side
  from DriveFileId.
- Spec updated: /google-drive/start now accepts ?date= query
  parameter (browser-driven flow can't set headers on top-level
  navigation); proxy v1.0.2 (deployed) implements both forms.

Bumped Microting.EformBackendConfigurationBase 10.0.32 -> 10.0.33.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements PR-4: wires the placeholder "Add Google Drive file" link
in the calendar attach-file modal to the OAuth + Picker flow added
in PR-3, renders Drive badge + open-in-Drive link on attachment
rows, and adds a small backend /picker-token endpoint for the
Picker JS to call Drive API.

- BackendConfigurationPnGoogleDriveService (Angular wrapper) for
  /status, /start, /picker-token, /attach.
- GoogleDriveOAuthFinishComponent — popup landing route, posts
  same-origin pinned message back to opener and self-closes.
- Modal: dynamic gapi/picker JS load, OAuth popup with X-Frame
  source check + opener.postMessage, mime filter pdf/png/jpeg
  matching backend allowlist, single-use postMessage listener
  cleaned up in ngOnDestroy, connecting flag prevents double-click
  during OAuth dance, popup-closed watcher releases the flag.
- 11 i18n keys (auto-translated to 26 locales). The "Open in
  Google Drive" key was renamed from an ambiguous "Open source"
  phrasing that machine-translators interpreted as the FOSS idiom.
- Backend: localized GenericError for catch-all branches (was
  leaking exception messages to clients), [Authorize]'d
  /picker-token returning {accessToken, developerKey}, OAuthFinish
  redirect points at the new google-drive-oauth-finish route.

Also fixes the integration-test SQL bootstrap snapshot to include
the v10.0.33 schema additions (DriveFileId/DriveModifiedTime/
GoogleOAuthTokenId on AreaRulePlanningFiles, plus four new tables
for GoogleOAuthToken/DriveWatchChannel + Versions). The previous
snapshot caused 10 schema-related test failures in CI.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements PR-5: ensures a Drive changes.watch subscription exists
after the first Drive-sourced attachment lands for a user, mints
an HS256 channel-token JWT (typ=channel) the proxy verifies on
fan-out, and adds the customer-side /notify endpoint that the
proxy POSTs to. Webhook is fire-and-forget per spec; PR-6's
reconcile cron is the safety net for missed deliveries.

- IGoogleDriveAuthService.EnsureWatchChannelAsync: idempotent on
  attach (>24h reuse, ≤24h renew with old-row soft-delete first),
  POSTs drive/v3/changes/watch with the proxy's /google-drive/notify
  as the fan-out address, persists the response.
- GoogleDriveFileService.DownloadAndCacheFileAsync calls Ensure
  for the first user-token-scoped Drive attachment. Watch failure
  logged at Warning, file persists regardless.
- GoogleDriveController.Notify [AllowAnonymous]: HMAC verify
  (canonical {channelId}|{resourceState}|{resourceId}|{messageNumber}|
  {date}, ±2 min skew), JWT verify (sig + typ=channel + exp),
  channel-row lookup (200 if stale per spec). Returns 200 fast,
  no Drive calls.
- Spec PR-5 binding: presented JWT is FixedTimeEquals'd against
  channel.SignedToken — defence-in-depth so future renewal paths
  that reuse a ChannelId don't accept stale tokens.
- HMAC reference-vector test pins canonical-string + key + digest
  to byte-exact values so a one-sided drift between proxy and
  customer fails the test even if the test's own helper drifts
  with the controller.

GoogleDriveOptions gains CustomerInstanceUrl for embedding in
the channel-token JWT (the proxy uses it to fan-out the webhook
back to the right customer instance).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements PR-7 of the Google Drive integration: a change processor
that re-downloads modified Drive files into the existing UploadedData
pipeline and handles the 404 / 403 lifecycle. Wired into PR-5's
/notify webhook so each notification kicks the matching user's files
on a fire-and-forget Task.Run with its own DI scope (the controller's
request-scoped DbContext can't survive the await boundary).

- GoogleDriveChangeProcessor.ProcessUserAsync iterates every Drive-
  sourced AreaRulePlanningFile for the user's active token, calls
  ProcessFileAsync per row, and tallies (Refreshed / Removed /
  NoChange / Errors). Token-revoked aborts the loop and inflates
  Errors by the remaining count so the invariant
  Refreshed+Removed+NoChange+Errors == input still holds.
- ProcessFileAsync probes Drive metadata first; only re-downloads
  when modifiedTime > cached, then delegates to the existing
  RefreshFileAsync. 404 -> WorkflowState=Removed (file deleted by
  user). 403 -> same (permission revoked). 5xx -> Error and the
  reconcile cron retries.
- Two new typed exceptions (DriveFileNotFoundException,
  DriveFilePermissionDeniedException) replace the previous bool
  return on RefreshFileAsync's metadata path so the processor can
  branch on outcome instead of HTTP-introspecting.
- /notify now resolves the processor in a fresh DI scope and
  Task.Run's it, returns 200 immediately. Unhandled exceptions
  inside the background task are logged via ILogger; the host is
  not at risk.
- Spec PR-7 step 4: structured Information audit log on every
  successful refresh (userId, driveFileId, file id,
  old/newModifiedTime, old/newSizeBytes) so PR-8's "last refreshed"
  tooltip and operator debugging both have a source.
- 6 integration tests cover refresh, no-op, 404, 403, token-revoked,
  and a multi-file mixed-outcome tally.

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

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

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

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

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

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

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

Implements PR-8 (final PR) of the Google Drive integration: lets a
user disconnect a Google Drive account, surfaces connected accounts
on a dedicated settings page, and decorates calendar attachments
with last-refreshed timestamps + a Drive-disconnected badge when
the backing token is gone.

Backend:
- IGoogleDriveAuthService.DisconnectAsync(tokenId, currentUserId):
  ownership check, clear in-memory access-token cache FIRST so any
  in-flight thread cannot re-populate post-revoke, decrypt the
  refresh token, POST to oauth2.googleapis.com/revoke (form-urlencoded
  body so the token never lands in URL logs), best-effort POST to
  drive/v3/channels/stop on every Created channel, soft-delete each
  channel row, set RevokedAt on the token, clear the cache again
  for defence-in-depth. Idempotent on Google's invalid_token reply
  (treats as already-revoked success).
- GetAccessTokenAsync hot path now re-checks the DB for an active
  token row before serving a cached access token — closes the race
  where a cached token kept working for ~50 minutes after revoke.
- DELETE /google-drive/disconnect/{tokenId}, GET /google-drive/accounts
  (active+revoked rows, ConnectedAt DESC) endpoints.
- CalendarTaskAttachmentDto gained LastRefreshedAt + DriveRevoked,
  populated via .Include(...).ThenInclude(GoogleOAuthToken) in
  MapAttachments — no N+1 on the calendar fetch.
- Navigation menu entry under Settings → "Connected Google Drive
  accounts" so the page is discoverable from the UI (not just by
  URL).

Frontend:
- BackendConfigurationPnGoogleDriveService.getAccounts/disconnect.
- GoogleDriveAccountsComponent (settings route): lists accounts
  with email, connected date, last-used relative time, status
  badge, and a Disconnect button on active rows that prompts a
  confirm dialog before calling the API.
- Calendar attachment row now shows a red "Google Drive
  disconnected" badge when f.driveRevoked, and a "Last refreshed N
  ago" tooltip on every Drive row.
- 9 i18n keys added; auto-translated and hand-spotted IT/ES/NL/SV/
  FR/DA/DE so "Drive" is preserved as the Google Drive brand
  rather than mistranslated as "disk drive".

Tests:
- 4 new integration tests cover happy disconnect, Google-already-
  revoked idempotency, cross-user authorization rejection, and
  GET /accounts ordering.

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

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

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

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

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

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

# Conflicts:
#	eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Services/BackendConfigurationCalendarService/IBackendConfigurationCalendarService.cs
feat(opgaver): UploadPhoto fieldId binding + Complete response parity
…iple tavle_ids

Replaces string tavle_id with repeated string tavle_ids on
ListOpgaverRequest and StreamOpgaveChangesRequest. The C# handlers
parse the repeated field directly into the existing BoardIds list
that BackendConfigurationCalendarService.ShouldIncludeTask already
filters on, so no business logic changes — only proto + glue.

Hard cutover (same field number, type change). The flutter consumer
is the only client of this proto; plugin and flutter PRs merge in
lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Simplifier polish on TryParseBoardIds: file already imports
System.Collections.Generic; bare List<int>/HashSet<int>/IEnumerable<string>
match the rest of OpgaverGrpcService. XML doc trimmed to the load-bearing
facts (per-entry skip semantics, dedup, empty=show-all contract).
Behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(opgaver): multi-tavle filter on ListOpgaver + StreamOpgaveChanges
…backend_configuration.Events

Plugin side of the Danish->English migration per
docs/superpowers/specs/2026-05-11-opgave-to-event-migration.md.
Renames proto package, service, the opgave-noun messages
(Opgave->Event, OpgaveChange->EventChange, CompleteOpgave*->CompleteEvent*),
and the corresponding C# handler class + registration. The proto file
itself moves opgaver.proto->events.proto.

Intentionally mixed-language: Ejendom, Tavle, rode fields stay Danish
this PR -- they're tracked in follow-up phases. Private locals inside
the handler are also out of scope per the plan's smallest-viable diff
ceiling. Helm chart already routes /backend_configuration.Events/ as
h2c so the mobile client cutover is lockstep but the wire path is
already wired through Traefik on the cluster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prior mechanical Opgaver->Event rename overshot and produced 6
references to non-existent Eventr* types. The private JSON envelope
classes (OpgaverCustomEnvelope, OpgaverCommentBody, OpgaverPhotoBody)
are explicitly carved out of the smallest-viable-diff per the migration
plan - they stay Danish. Restore the references.
Simplifier pass: trimmed redundant header + collapsed paragraph
structure from 16 to 11 lines. Same load-bearing info: spec link,
exact noun scope (opgave only), Ejendom/Tavle/rode deferral, field-
name preservation rationale, smallest-viable-diff framing.
feat(opgaver): rename gRPC surface from microting.opgaver.Opgaver to backend_configuration.Events
Previously the test tampered the LAST base64url char of the JWT
signature. For a 32-byte HMAC-SHA256 → 43 base64url chars (no
padding), the last char encodes only 4 significant bits of byte 31;
the other 2 bits are padding bits the encoder always emits as 00.
.NET's Convert.FromBase64String silently ignores those padding
bits — so flipping the last char between 'A' (000000) and 'B'
(000001) is a no-op for the decoded signature byte whenever byte
31's low-4-bits == 0. That happens ~1/16 of the time (uniform
random HMAC output). Empirically: 6.33% undetected over 10000
trials.

CI manifested the bug on
https://github.com/microting/eform-backendconfiguration-plugin/actions/runs/25660940289
(stable HEAD after PR #802) — not a regression introduced by that
rename, just a long-standing 6% flake that finally rolled
snake-eyes.

Fix: tamper a char in the MIDDLE of the signature segment instead.
Middle chars use all 6 bits as data, so any flip is guaranteed to
mutate the decoded signature. Empirically 0/10000 false-passes.

Production verifier in GoogleDriveAuthService.VerifyEnvelopeJwt is
correct as-is (FixedTimeEquals on the decoded byte arrays); only
the test's tamper logic was unsound.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of the Phase 1 plugin pattern (PR #802) for the next noun in the
opgave→event migration: rename the gRPC message types and RPC method
names from Tavle to Board. Wire field names stay (`tavler`, `tavle_id`,
`tavle_ids`) so generated C# accessors don't churn — only the type
names and RPC method move.

Concrete proto changes:
- rpc ListTavler(ListTavlerRequest) returns (ListTavlerResponse)
  → rpc ListBoards(ListBoardsRequest) returns (ListBoardsResponse)
- message Tavle → message Board
- message ListTavlerRequest/Response → ListBoardsRequest/Response
- Top-of-file comment updated to document Phase 2 boundaries.

Concrete handler changes (EventsGrpcService.cs):
- public override Task<ListTavlerResponse> ListTavler(...)
  → Task<ListBoardsResponse> ListBoards(...)
- new Tavle { ... } → new Board { ... }
- response.Tavler.Add(...) kept (generated from carved wire field)
- All 15 generated accessors (request.TavleIds reads, event.TavleId
  setters) untouched — they're driven by carved wire fields.

Build: dotnet clean + build green from eFormAPI.Web (0 errors,
10 pre-existing warnings). Backend restarts cleanly on :5000+:5001.
Embedded copy at eform-angular-frontend/eFormAPI/Plugins/BackendConfiguration.Pn/
synced (diff -q silent).

Spec: docs/superpowers/specs/2026-05-11-opgave-to-event-migration.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@renemadsen renemadsen merged commit b84b576 into master May 12, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant