Build Unit Assignment Service with constraint enforcement
epic-organizational-hierarchy-management-assignment-aggregation-task-008 — Implement the service managing user-to-organizational-unit assignments. Enforce the single-primary-unit-per-organization constraint, validate NHF's 5-chapter maximum per user, integrate with Active Chapter State for session context updates when primary assignment changes, and expose assign, unassign, and list-assignments operations consumed by coordinator-facing panels.
Acceptance Criteria
Technical Requirements
Execution Context
Tier 4 - 323 tasks
Can start after Tier 3 completes
Implementation Notes
Implement atomic primary demotion as a Supabase RPC function set_primary_assignment(p_user_id uuid, p_unit_id uuid) that in a single transaction: (1) sets is_primary = false on all existing primary assignments for that user in that organization, (2) inserts or updates the new assignment with is_primary = true. This prevents the dual-primary race condition that would occur if Dart issued two sequential UPDATE/INSERT calls. In the Dart service, call this RPC via supabase.rpc('set_primary_assignment', params: {...}). The 5-chapter limit check should query COUNT from unit_assignments WHERE user_id = userId before calling the RPC — pass the snapshot count to HierarchyStructureValidator.validateUserDepthLimit() to keep the validator pure.
Inject ActiveChapterCubit via the service constructor; call switchChapter() after a successful primary assignment write to ensure the state update is not skipped on RPC error.
Testing Requirements
Unit tests using mockito/mocktail to mock UnitAssignmentRepository, HierarchyStructureValidator, and ActiveChapterCubit. Cover: (1) assign success, (2) assign with primary demotion — assert repository receives demotion call before new assignment, (3) assign rejected at depth limit — assert no repository write occurs, (4) assign idempotency, (5) unassign primary — assert ActiveChapterCubit.switchToEmpty() called, (6) unassign non-primary — assert cubit not notified, (7) listAssignments correct ordering. Integration test against local Supabase: create two chapter assignments for a test user, mark second as primary, assert first is demoted atomically. Minimum 85% branch coverage.
Recursive aggregation queries across four hierarchy levels (national → region → local) with 1,400 leaf nodes may be too slow for real-time dashboard requests, exceeding the 200ms target and causing spinner timeouts.
Mitigation & Contingency
Mitigation: Implement aggregation as a Supabase RPC using a single recursive CTE rather than multiple round-trip queries. Pre-compute aggregations nightly via a scheduled Edge Function and cache results. For real-time needs, aggregate only the immediate subtree on demand.
Contingency: Surface a 'Refreshing...' indicator and serve stale cached aggregations immediately. Queue an async recalculation and push updated data via Supabase Realtime when ready, avoiding blocking the admin dashboard.
The 5-chapter limit and primary-assignment constraint are NHF-specific. Applying these rules globally may break HLF and Blindeforbundet configurations where different limits apply, requiring per-organization configuration that was not initially scoped.
Mitigation & Contingency
Mitigation: Make the maximum assignment count a configurable value stored in the organization's feature-flag or settings table rather than a hardcoded constant. Design the assignment service to read this limit at runtime per organization.
Contingency: Default the limit to a high value (e.g., 100) for organizations other than NHF, effectively making it non-restrictive, while keeping the enforcement logic intact for when per-org configuration is fully implemented.
The searchable parent dropdown in HierarchyNodeEditor must search across up to 1,400 units efficiently. Client-side filtering of the full hierarchy may be slow; server-side search adds complexity and latency.
Mitigation & Contingency
Mitigation: Use the in-memory hierarchy cache as the search corpus — since the cache already holds the flat unit list, client-side filtering with a debounced input is sufficient and avoids extra Supabase calls. Pre-build a search index on cache load.
Contingency: Cap the dropdown to showing the 50 most recently accessed units by default, with a 'search all' option that triggers a server-side full-text query. This keeps the common case fast while supporting edge cases.