Expose aggregated attribution counts for dashboards
epic-membership-recruitment-core-services-task-009 — Implement the getAttributionCountsForMentor method in ReferralAttributionService. This method queries the repository for all attribution records belonging to a specific mentor and returns a structured summary containing: total clicks, pending attributions, confirmed attributions, and conversion rate. The result must be suitable for direct consumption by the Recruitment Stats Widget and Coordinator Dashboard components.
Acceptance Criteria
Technical Requirements
Execution Context
Tier 4 - 323 tasks
Can start after Tier 3 completes
Implementation Notes
Implement repository aggregation as a Supabase RPC function (`get_attribution_counts`) if performance testing shows client-side counting is too slow for mentors with thousands of click events. The RPC takes `p_mentor_id uuid` and returns a single row with columns `total_clicks bigint, pending_count bigint, confirmed_count bigint`. For the initial implementation, two parallel queries are acceptable: (1) COUNT of click_events joined to referral_codes where mentor_id matches; (2) COUNT of attribution_records by mentor_id grouped by status. Use `Future.wait([clickQuery, attributionQuery])` to run them in parallel.
Build the AttributionCounts model from the results and compute conversionRate in Dart. Add a `@freezed` annotation (or manual copyWith/equality) to AttributionCounts so Riverpod's select() diffing works correctly when used in widgets. Expose a `referralAttributionCountsProvider(mentorId)` FutureProvider.family alongside the service method for convenient widget consumption.
Testing Requirements
Unit tests (flutter_test + Mockito): mock IRecruitmentAttributionRepository with controlled return values. Test cases: (1) 10 clicks, 3 pending, 2 confirmed → conversionRate = 0.2; (2) 0 clicks, 0 attributions → all zeros, no exception; (3) 5 clicks, 0 attributions → conversionRate = 0.0; (4) repository throws → exception propagated. Assert that the AttributionCounts fields are calculated correctly, not just non-null. Test the zero-division guard explicitly.
No UI tests required at this layer; widget tests for Recruitment Stats Widget are out of scope for this task.
Confirmed registration events originate from the membership system (Dynamics portal for HLF), which may call back asynchronously with significant delay. If the attribution service only accepts synchronous confirmation at registration time, late callbacks will fail to match the originating referral code, resulting in under-counted conversions.
Mitigation & Contingency
Mitigation: Design the attribution confirmation path as a webhook endpoint (Supabase Edge Function) that accepts a referral_code + new_member_id pair at any time after click. The service matches by code string, not by session. Persist pending_signup events immediately at onboarding screen submission so there is always a record to upgrade to 'confirmed' when the webhook fires.
Contingency: If the membership system cannot reliably call the webhook, implement a polling reconciliation job (Supabase pg_cron, daily) that queries the membership system for recently registered members and back-fills any unmatched attribution records.
If confirmRegistration() is called more than once for the same new member (e.g., idempotency retry from the webhook), duplicate milestone events could be emitted, causing the badge system to award badges multiple times.
Mitigation & Contingency
Mitigation: Use a UNIQUE constraint on (referral_code_id, new_member_id) in the referral_events table for confirmed events. The confirmRegistration() method uses upsert semantics; milestone evaluation reads the confirmed count from the aggregation query rather than counting individual calls.
Contingency: If duplicate awards occur in production, the badge system should support idempotent award checks (query existing badges before awarding). Add a deduplication guard in BadgeCriteriaIntegration as a secondary defence.
Stakeholder review may expand attribution requirements mid-epic to include click-through tracking per channel (WhatsApp vs SMS vs email), which is not currently in scope but was mentioned in user story discussions. This would require schema changes in the foundation epic and delay delivery.
Mitigation & Contingency
Mitigation: Capture per-channel data in the device_metadata JSONB field from day one as an unstructured field (share_channel: 'whatsapp'). This preserves data without requiring a schema column, allowing structured querying to be added later without migrations.
Contingency: If channel-level analytics become a hard requirement during this epic, timebox the change to adding a nullable channel column to referral_events and a corresponding filter parameter on the aggregation query, deferring dashboard UI to a separate task.