high priority low complexity backend pending backend specialist Tier 3

Acceptance Criteria

FilterCriteria has a specialisationTags field of type List<String> (empty list means no tag filter)
MentorFilterService.validate() removes duplicate tags and trims whitespace from each tag; an empty string tag after trimming is removed
MentorFilterService.validate() throws ValidationException if specialisationTags contains more than 20 tags (sanity upper bound)
MentorFilterService.toQueryParameters() emits no tag clause when specialisationTags is empty
MentorFilterService.toQueryParameters() emits an .overlaps('specialisation_tags', [...]) clause (OR semantics) when specialisationTags is non-empty
getAvailableTagVocabulary(String organisationId) returns Future<List<String>> fetched from the Supabase mentor_tag_vocabulary table filtered by organisationId, sorted alphabetically
getAvailableTagVocabulary returns an empty list (not an error) when no tags are defined for the organisation
Unit test: empty tags list → no tag clause in output
Unit test: ['hearing-loss'] → overlaps clause with single-element array
Unit test: ['hearing-loss', 'mobility'] → overlaps clause with two-element array
Unit test: ['hearing-loss', 'hearing-loss'] (duplicate) → deduplicated to single tag
Unit test: [' hearing-loss '] (whitespace) → normalised to 'hearing-loss'
Unit test: getAvailableTagVocabulary returns alphabetically sorted list from mock Supabase response
Tags are treated as case-insensitive on normalisation (lowercased during validate())

Technical Requirements

frameworks
Flutter
flutter_test
apis
Supabase PostgREST .overlaps() query operator
Supabase mentor_tag_vocabulary table query
data models
FilterCriteria
TagVocabularyEntry (id, organisationId, tag)
performance requirements
Tag vocabulary fetch is cached in-memory for the session duration to avoid repeated Supabase round-trips when the filter panel is opened multiple times
Normalisation (dedup + trim + lowercase) must be O(n) in tag count
security requirements
Tag strings from the vocabulary API must be HTML-escaped before display in the UI layer (enforcement documented here as a contract for the UI task)
Organisation ID must be validated as a non-empty UUID before querying the vocabulary table to prevent injection

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

In FilterCriteria, initialise specialisationTags as const [] (not null) to avoid null checks throughout the codebase. Normalisation in validate() should use a LinkedHashSet to deduplicate while preserving insertion order, then convert to a sorted list for deterministic query generation. The .overlaps() Supabase operator maps to the Postgres && array operator — confirm the database column is a text[] or varchar[] type and not JSONB; if JSONB, use .contains() instead. For getAvailableTagVocabulary, add a simple in-memory cache using a Map> keyed by organisationId, cleared on logout.

Expose a clearVocabularyCache() method for testing. The organisation-scoped vocabulary directly supports the multi-org architecture described in the app (NHF, HLF, Blindeforbundet each define their own tag sets — e.g., 'hearing-loss', 'mobility').

Testing Requirements

Pure unit tests in flutter_test for all filter logic. Mock the Supabase client for getAvailableTagVocabulary tests using mocktail — test the success path, empty-result path, and network error path (expecting a propagated exception). Write parameterised tests for the normalisation logic covering: duplicates, mixed case, leading/trailing whitespace, empty string removal. Verify OR semantics: given a mock list of MentorLocation with tags ['hearing-loss'], ['mobility'], ['hearing-loss','mobility'], and a filter of ['hearing-loss'], confirm all mentors with 'hearing-loss' are included and the mobility-only mentor is excluded (this is an integration-level assertion on the Supabase overlaps semantics — document as an integration test to run against the emulator).

Target 100% branch coverage on tag normalisation and clause generation.

Component
Mentor Filter Service
service low
Epic Risks (2)
medium impact medium prob technical

The dual BLoC state machines (map view state + filter state) may introduce subtle synchronisation bugs where filter changes do not correctly re-trigger viewport queries, causing stale data to appear on the map.

Mitigation & Contingency

Mitigation: Define all BLoC state transitions in a state diagram before implementation. Use flutter_bloc's BlocObserver in development mode to log every state transition. Write explicit unit tests for filter-change → re-query transitions.

Contingency: If state synchronisation bugs appear in integration testing, refactor to a single unified BLoC that owns both map viewport state and filter state, eliminating cross-BLoC dependencies.

low impact medium prob scope

Cached mentor location data may become stale (mentors move, pause, or revoke consent) and coordinators in offline mode could be shown incorrect mentor information, leading to wasted outreach.

Mitigation & Contingency

Mitigation: Display a clear timestamp on cached data indicating when it was last synced. Set cache TTL to 24 hours and show an 'offline — data from [date]' banner. Revoked consent removes the mentor from the cache on next successful sync via contact-cache-sync-repository.

Contingency: If cache staleness causes user complaints, reduce TTL to 4 hours and implement background sync on app foreground. Accept that very-recently-revoked mentors may appear briefly in offline mode — document this as a known limitation in the privacy policy.