Implement Badge Award Service
epic-achievement-badges-ui-task-007 — Create the badge-award-service that handles the business logic of awarding a badge to a peer mentor: validates the award is not a duplicate, writes the earned record via badge-repository, emits a badge-earned domain event, and returns the awarded badge model. Ensure idempotency to handle retry scenarios.
Acceptance Criteria
Technical Requirements
Execution Context
Tier 1 - 540 tasks
Can start after Tier 0 completes
Implementation Notes
Implement idempotency using a Supabase upsert with ON CONFLICT DO NOTHING on the (mentor_id, badge_definition_id) unique constraint, then inspect the returned row count to determine whether this was a new award or a duplicate. This avoids a separate SELECT before INSERT and removes the race condition window. Emit the BadgeEarnedEvent only when row count > 0 (i.e., a new row was inserted). The service should be a plain Dart class injected via the dependency injection container — avoid any Flutter widget dependencies.
Wrap the upsert + event emission in a try/catch; if the upsert succeeds but event emission fails, log the error but do not roll back (event bus failure is non-fatal; the badge is already persisted).
Testing Requirements
Unit tests using mocktail: mock BadgeRepository and BadgeDefinitionRepository. Test cases: (1) first award → insert called, event emitted, EarnedBadge returned; (2) duplicate award → insert NOT called, event NOT emitted, existing record returned; (3) unknown badgeDefinitionId → BadgeDefinitionNotFoundException; (4) invalid UUID inputs → ArgumentError. Integration test against a Supabase test instance: verify upsert idempotency at the database level. Minimum 90% branch coverage.
The badge-earned-celebration overlay must appear within 2 seconds of the triggering activity being saved, but badge evaluation runs server-side in an edge function triggered by a database webhook. Network latency, edge function cold start, and Supabase Realtime delivery delays could cause the overlay to appear late or not at all, breaking the motivational loop.
Mitigation & Contingency
Mitigation: Implement an optimistic UI path: after activity save, badge-bloc immediately checks whether any badge thresholds are crossed client-side using cached stats and badge definitions, showing the overlay speculatively before server confirmation. The server result then reconciles. Subscribe to Supabase Realtime on the earned_badges table for authoritative confirmation.
Contingency: If Realtime delivery is unreliable in production, add a polling fallback: badge-bloc polls for new earned badges 3 seconds after an activity save and shows the overlay if a new record is detected, accepting up to 5-second latency as a fallback SLA.
The celebration overlay uses animation for positive reinforcement, but motion sensitivity (prefers-reduced-motion) and screen reader users require a non-animated or text-only alternative. Failing to handle this risks excluding Blindeforbundet users or triggering vestibular discomfort for motion-sensitive volunteers.
Mitigation & Contingency
Mitigation: Check MediaQuery.disableAnimations in badge-earned-celebration-overlay and skip animation entirely when true, showing a static card instead. Add an ExcludeSemantics wrapper around the decorative animation widget and a separate Semantics node with a live region announcement of the badge name and congratulatory message.
Contingency: If accessibility issues are identified in TestFlight testing with Blindeforbundet's test group, fast-track a patch that defaults to the static card path and gates the animation behind a user preference setting in notification preferences.