critical priority medium complexity backend pending backend specialist Tier 1

Acceptance Criteria

`awardBadge(mentorId, badgeDefinitionId, orgId)` returns a `Future<EarnedBadge>` containing the full record including `earned_at`, `id`, `mentor_id`, `badge_definition_id`, and `org_id`
Calling `awardBadge` twice with the same (mentorId, badgeDefinitionId) returns the original `EarnedBadge` on the second call without throwing and without creating a duplicate row
The `earned_at` timestamp is set by the Supabase RPC function using `now()` (server time), never by the client; client-supplied timestamps are ignored
The upsert uses `onConflict: 'mentor_id,badge_definition_id'` and returns the existing row on conflict (`ignoreDuplicates: false, returning: minimal` or equivalent that returns the row)
The entire award operation is executed inside a single Supabase RPC call (e.g. `award_badge_idempotent`) to guarantee atomicity — no multi-step client-side logic
If `badgeDefinitionId` does not exist or belongs to a different org than `orgId`, the RPC returns an error and the Dart method throws a `BadgeAwardException` with a descriptive message
If `mentorId` does not exist or is not a member of `orgId`, the RPC returns an error and the Dart method throws a `BadgeAwardException`
Network or Supabase errors are wrapped in `BadgeAwardException` with the original error attached as a cause, not swallowed
The returned `EarnedBadge` object is a fully typed Dart class with `fromJson` / `toJson` and equality based on `id`
The method is callable concurrently for the same mentor+badge combination without creating race conditions or duplicate rows

Technical Requirements

frameworks
Flutter
Riverpod
Dart
supabase_flutter
apis
Supabase RPC — `award_badge_idempotent(mentor_id, badge_definition_id, org_id)` returning earned_badges row
Supabase PostgREST — earned_badges table with composite UNIQUE constraint
data models
EarnedBadge
BadgeDefinition
PeerMentor
performance requirements
RPC call must complete in < 500 ms under normal network conditions
Idempotent second call (conflict path) must return in < 300 ms — the DB conflict resolution should be fast
No client-side retry loops — idempotency is handled by the DB constraint, not retry logic
security requirements
RPC function must verify that the calling user's role permits awarding badges (coordinator or system process, not the mentor themselves)
RPC must validate that badgeDefinitionId.org_id matches the supplied orgId to prevent cross-org badge awards
Supabase RLS on earned_badges must prevent direct insert/update by the client — all writes must go through the RPC
mentorId and badgeDefinitionId must be UUIDs; the RPC should reject malformed inputs at the DB level

Execution Context

Execution Tier
Tier 1

Tier 1 - 540 tasks

Can start after Tier 0 completes

Implementation Notes

Write the Supabase RPC function `award_badge_idempotent` in SQL using `INSERT INTO earned_badges (...) VALUES (...) ON CONFLICT (mentor_id, badge_definition_id) DO UPDATE SET updated_at = now() RETURNING *` — the DO UPDATE clause is needed to trigger RETURNING on conflict (DO NOTHING does not return the existing row in all Postgres versions). Ensure the composite UNIQUE constraint exists as a proper database constraint, not just enforced in application code. In Dart, call `supabase.rpc('award_badge_idempotent', params: {...})` and map the single returned row to `EarnedBadge.fromJson`. Keep `BadgeAwardService` stateless — it holds no cache and no state; cache management belongs to the Riverpod provider layer in task-010.

Model `BadgeAwardException` with a `code` enum (notFound, crossOrgViolation, permissionDenied, networkError) so callers can present specific error messages rather than generic ones.

Testing Requirements

Integration tests with a test Supabase instance (or mocked Supabase client): verify first call creates a row and returns full EarnedBadge; verify second call with same inputs returns the same row (same `id`, same `earned_at`) without error; verify concurrent calls (use Dart `Future.wait`) produce exactly one row; verify error is thrown for non-existent badge definition; verify error is thrown for cross-org badge definition; verify error is thrown for non-member mentorId. Unit test the Dart `BadgeAwardService` class with a mock Supabase client to verify error wrapping and response mapping. Test `EarnedBadge.fromJson` with sample Supabase response payloads including null optional fields.

Component
Badge Award Service
service medium
Epic Risks (3)
high impact medium prob technical

peer-mentor-stats-aggregator must compute streaks and threshold counts across potentially hundreds of activity records per peer mentor. Naive queries (full table scans or N+1 patterns) will cause slow badge evaluation, especially when triggered on every activity save for all active peer mentors.

Mitigation & Contingency

Mitigation: Design aggregation queries using Supabase RPCs with window functions or materialised views from the start. Add database indexes on (peer_mentor_id, activity_date, activity_type) before writing any service code. Profile all aggregation queries against a dataset of 500+ activities during development.

Contingency: If query performance is insufficient at launch, implement incremental stat caching: maintain a peer_mentor_stats snapshot table updated on each activity insert via a database trigger, so the aggregator reads from pre-computed values rather than scanning raw activity rows.

medium impact low prob technical

badge-award-service must be idempotent, but if two concurrent edge function invocations evaluate the same peer mentor simultaneously (e.g., from a rapid double-save), both could pass the uniqueness check before either commits, resulting in duplicate badge records.

Mitigation & Contingency

Mitigation: Rely on the database-level uniqueness constraint (peer_mentor_id, badge_definition_id) as the final guard. In the service layer, use an upsert with ON CONFLICT DO NOTHING and return the existing record. Add a Postgres advisory lock or serialisable transaction for the award sequence during the edge function integration epic.

Contingency: If duplicate records are discovered in production, run a deduplication migration to remove extras (keeping earliest earned_at) and add a unique index if not already present. Alert engineering via Supabase database webhook on constraint violations.

medium impact medium prob scope

The badge-configuration-service must validate org admin-supplied criteria JSON on save, but the full range of valid criteria types (threshold, streak, training-completion, tier-based) may not be fully enumerated during development, leading to either over-permissive or over-restrictive validation that frustrates admins.

Mitigation & Contingency

Mitigation: Define a versioned Dart sealed class hierarchy for CriteriaType before writing the validation logic. Review the hierarchy with product against all known badge types across NHF, Blindeforbundet, and HLF before implementation. Build the validator against the sealed class so new criteria types require an explicit code addition.

Contingency: If admins encounter validation rejections for legitimate criteria, expose a 'criteria_raw' escape hatch (JSON passthrough, admin-only) with a product warning, and schedule a sprint to formalise the new criteria type properly.