diff --git a/backend/.env.example b/backend/.env.example index f0a1e9b2..72d83c46 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -34,6 +34,9 @@ SIWE_DOMAIN=localhost:5173 # Blockchain Settings - Shared RPC (both networks on same chain) VALIDATOR_RPC_URL=https://rpc.testnet-chain.genlayer.com +# Optional Web3 HTTP bounds (defaults shown; retries are after the initial request) +WEB3_RPC_TIMEOUT_SECONDS=10 +WEB3_RPC_MAX_RETRIES=1 # Asimov Testnet (legacy VALIDATOR_CONTRACT_ADDRESS/FACTORY_CONTRACT_ADDRESS also supported) ASIMOV_STAKING_CONTRACT_ADDRESS=0x63Fa5E0bb10fb6fA98F44726C5518223F767687A diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index b09eb4f5..a9d10cb1 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -533,6 +533,8 @@ Example response: ## Environment Variables Located in `.env` file: - `VALIDATOR_RPC_URL` - Blockchain RPC endpoint +- `WEB3_RPC_TIMEOUT_SECONDS` - Optional timeout in seconds for validator Web3 HTTP requests (default `10`) +- `WEB3_RPC_MAX_RETRIES` - Optional number of retries after the initial validator Web3 HTTP request (default `1`) - `VALIDATOR_CONTRACT_ADDRESS` - Smart contract address - `SECRET_KEY` - Django secret key - `DEBUG` - Debug mode flag diff --git a/backend/community_xp/tests/test_membership.py b/backend/community_xp/tests/test_membership.py new file mode 100644 index 00000000..ae0aa6e0 --- /dev/null +++ b/backend/community_xp/tests/test_membership.py @@ -0,0 +1,266 @@ +from datetime import timedelta + +from django.test import TestCase, override_settings +from django.utils import timezone + +from community_xp.models import Mee6CurrentXP, Mee6SyncRun +from community_xp.utils import get_community_member_user_ids +from contributions.models import ( + Category, + Contribution, + ContributionDiscordXPState, + ContributionType, +) +from creators.models import Creator +from leaderboard.models import GlobalLeaderboardMultiplier +from poaps.models import PoapClaim, PoapDrop +from social_tasks.models import SocialTask, SocialTaskCompletion +from users.models import User + + +@override_settings(MEE6_GUILD_ID='guild-1') +class CommunityMembershipTest(TestCase): + def setUp(self): + self.community_category, _ = Category.objects.get_or_create( + slug='community', + defaults={'name': 'Community'}, + ) + self.community_type = ContributionType.objects.create( + name='Community Membership Post', + slug='community-membership-post', + category=self.community_category, + max_points=10_000, + ) + self.link_type, _ = ContributionType.objects.update_or_create( + slug='community-link-x', + defaults={ + 'name': 'Community Membership Link', + 'category': self.community_category, + 'min_points': 0, + 'max_points': 500, + }, + ) + for contribution_type in (self.community_type, self.link_type): + GlobalLeaderboardMultiplier.objects.get_or_create( + contribution_type=contribution_type, + defaults={ + 'multiplier_value': 1, + 'valid_from': timezone.now() - timedelta(days=30), + }, + ) + self.drop = PoapDrop.objects.create( + title='Membership POAP', + slug='membership-poap', + event_start_at=timezone.now(), + status=PoapDrop.STATUS_ACTIVE, + ) + self._user_index = 0 + self._task_index = 0 + + def create_user(self, *, visible=True): + self._user_index += 1 + suffix = f'{self._user_index:040x}' + return User.objects.create_user( + email=f'member-{self._user_index}@example.com', + password='pass', + address=f'0x{suffix}', + visible=visible, + ) + + def create_sync(self, *, guild_id='guild-1', completed_at=None): + completed_at = completed_at or timezone.now() + return Mee6SyncRun.objects.create( + guild_id=guild_id, + status=Mee6SyncRun.STATUS_SUCCESS, + completed_at=completed_at, + applied_at=completed_at, + ) + + def create_xp(self, user, *, guild_id='guild-1', xp=100): + run = self.create_sync(guild_id=guild_id) + return Mee6CurrentXP.objects.create( + guild_id=guild_id, + discord_id=f'discord-{guild_id}-{user.id}', + rank=1, + xp=xp, + sync_run=run, + matched_user=user, + synced_at=run.completed_at, + ) + + def create_completion(self, user, *, points=100): + self._task_index += 1 + task = SocialTask.objects.create( + slug=f'membership-task-{self._task_index}', + name=f'Membership task {self._task_index}', + category=self.community_category, + points=points, + verification_type='click_through', + action_url='https://example.com', + ) + return SocialTaskCompletion.objects.create( + user=user, + task=task, + points_awarded=points, + verification_type='click_through', + ) + + def create_contribution(self, user, *, contribution_type=None, points=100): + return Contribution.objects.create( + user=user, + contribution_type=contribution_type or self.community_type, + points=points, + contribution_date=timezone.now(), + ) + + def test_positive_xp_uses_selected_guild_visibility_and_user_ids(self): + selected = self.create_user() + other_guild = self.create_user() + zero_xp = self.create_user() + hidden = self.create_user(visible=False) + self.create_xp(selected) + self.create_xp(other_guild, guild_id='guild-2') + self.create_xp(zero_xp, xp=0) + self.create_xp(hidden) + + self.assertEqual( + get_community_member_user_ids(guild_id='guild-1'), + {selected.id}, + ) + self.assertEqual( + get_community_member_user_ids( + user_ids=[other_guild.id, hidden.id], + guild_id='guild-2', + visible_only=False, + ), + {other_guild.id}, + ) + + def test_pending_social_points_follow_applied_baseline_rules(self): + baseline_at = timezone.now() + self.create_sync(completed_at=baseline_at) + partial_pending = self.create_user() + covered_distribution = self.create_user() + post_baseline_distribution = self.create_user() + hidden_pending = self.create_user(visible=False) + + partial_completion = self.create_completion(partial_pending) + partial_state = partial_completion.discord_xp_state + partial_state.awarded_amount = 75 + partial_state.save(update_fields=['awarded_amount', 'updated_at']) + + covered_completion = self.create_completion(covered_distribution) + covered_state = covered_completion.discord_xp_state + covered_state.status = ContributionDiscordXPState.STATUS_DISTRIBUTED + covered_state.awarded_amount = covered_completion.points_awarded + covered_state.distributed_at = baseline_at - timedelta(minutes=1) + covered_state.save(update_fields=[ + 'status', 'awarded_amount', 'distributed_at', 'updated_at', + ]) + + post_completion = self.create_completion(post_baseline_distribution) + post_state = post_completion.discord_xp_state + post_state.status = ContributionDiscordXPState.STATUS_DISTRIBUTED + post_state.awarded_amount = post_completion.points_awarded + post_state.distributed_at = baseline_at + timedelta(minutes=1) + post_state.save(update_fields=[ + 'status', 'awarded_amount', 'distributed_at', 'updated_at', + ]) + self.create_completion(hidden_pending) + + self.assertEqual( + get_community_member_user_ids(), + {partial_pending.id, post_baseline_distribution.id}, + ) + self.assertEqual( + get_community_member_user_ids(visible_only=False), + {partial_pending.id, post_baseline_distribution.id, hidden_pending.id}, + ) + + def test_contributions_and_poaps_preserve_eligibility_filters(self): + contributor = self.create_user() + link_only = self.create_user() + poap_member = self.create_user() + hidden_poap_member = self.create_user(visible=False) + self.create_contribution(contributor) + self.create_contribution(link_only, contribution_type=self.link_type) + PoapClaim.objects.create( + drop=self.drop, + user=poap_member, + claim_method=PoapClaim.CLAIM_ADMIN, + ) + PoapClaim.objects.create( + drop=self.drop, + user=hidden_poap_member, + claim_method=PoapClaim.CLAIM_ADMIN, + ) + + self.assertEqual( + get_community_member_user_ids(), + {contributor.id, poap_member.id}, + ) + self.assertEqual( + get_community_member_user_ids(user_ids=[link_only.id, poap_member.id]), + {poap_member.id}, + ) + + def test_since_uses_recent_events_and_conditional_creator_activation(self): + now = timezone.now() + since = now - timedelta(days=30) + recent_contributor = self.create_user() + old_contributor = self.create_user() + recently_activated = self.create_user() + creator_only = self.create_user() + recent_social_member = self.create_user() + recent_poap_member = self.create_user() + old_poap_member = self.create_user() + + recent_contribution = self.create_contribution(recent_contributor) + old_contribution = self.create_contribution(old_contributor) + activation_contribution = self.create_contribution(recently_activated) + Contribution.objects.filter( + id__in=[old_contribution.id, activation_contribution.id], + ).update(created_at=since - timedelta(days=1)) + + Creator.objects.create(user=recently_activated) + Creator.objects.create(user=creator_only) + + social_completion = self.create_completion(recent_social_member) + social_state = social_completion.discord_xp_state + social_state.status = ContributionDiscordXPState.STATUS_DISTRIBUTED + social_state.awarded_amount = social_completion.points_awarded + social_state.distributed_at = now - timedelta(minutes=1) + social_state.save(update_fields=[ + 'status', 'awarded_amount', 'distributed_at', 'updated_at', + ]) + self.create_sync(completed_at=now) + + recent_claim = PoapClaim.objects.create( + drop=self.drop, + user=recent_poap_member, + claim_method=PoapClaim.CLAIM_ADMIN, + ) + old_claim = PoapClaim.objects.create( + drop=self.drop, + user=old_poap_member, + claim_method=PoapClaim.CLAIM_ADMIN, + ) + PoapClaim.objects.filter(id=old_claim.id).update( + created_at=since - timedelta(days=1), + ) + + self.assertGreaterEqual(recent_contribution.created_at, since) + self.assertGreaterEqual(recent_claim.created_at, since) + self.assertEqual( + get_community_member_user_ids(since=since), + { + recent_contributor.id, + recently_activated.id, + recent_social_member.id, + recent_poap_member.id, + }, + ) + self.assertNotIn( + creator_only.id, + get_community_member_user_ids(since=since), + ) diff --git a/backend/community_xp/utils.py b/backend/community_xp/utils.py index 7179c9bf..dfd77eb2 100644 --- a/backend/community_xp/utils.py +++ b/backend/community_xp/utils.py @@ -111,13 +111,12 @@ def _pending_points_case(points_field, state_field, baseline_completed_at=None): ) -def build_effective_community_scores_queryset(user_ids=None, guild_id=None, visible_only=True): - """ - Return users annotated with the same effective community score fields as - build_effective_community_scores(), without materializing the full ranking. - Effective points are MEE6 current XP plus contribution and social-task - points not covered by the applied MEE6 baseline. - """ +def _build_effective_community_scores_queryset( + user_ids=None, + guild_id=None, + visible_only=True, + include_details=True, +): from users.models import User guild_id = str(guild_id or get_default_guild_id()) @@ -166,102 +165,144 @@ def build_effective_community_scores_queryset(user_ids=None, guild_id=None, visi ))) .values('pending_total')[:1] ) - all_time_points_queryset = ( - community_contributions - .annotate(total=Sum('frozen_global_points')) - .values('total')[:1] - ) - contribution_count_queryset = ( - community_contributions - .annotate(count=Count('id')) - .values('count')[:1] - ) - all_time_social_task_points_queryset = ( - community_social_tasks - .annotate(total=Sum('points_awarded')) - .values('total')[:1] - ) - social_task_count_queryset = ( - community_social_tasks - .annotate(count=Count('id')) - .values('count')[:1] - ) + annotations = { + 'discord_xp': Coalesce( + Subquery(current_xp_queryset.values('xp')[:1], output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ), + 'pending_portal_points': Coalesce( + Subquery(pending_points_queryset, output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ), + 'pending_social_task_points': Coalesce( + Subquery(pending_social_task_points_queryset, output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ), + } + selected_fields = ('id', 'name') - return ( - user_queryset - .only('id', 'name', 'address', 'profile_image_url', 'visible') - .annotate( - discord_xp=Coalesce( - Subquery(current_xp_queryset.values('xp')[:1], output_field=IntegerField()), - Value(0), - output_field=IntegerField(), - ), - discord_xp_synced_at=Subquery( + if include_details: + all_time_points_queryset = ( + community_contributions + .annotate(total=Sum('frozen_global_points')) + .values('total')[:1] + ) + contribution_count_queryset = ( + community_contributions + .annotate(count=Count('id')) + .values('count')[:1] + ) + all_time_social_task_points_queryset = ( + community_social_tasks + .annotate(total=Sum('points_awarded')) + .values('total')[:1] + ) + social_task_count_queryset = ( + community_social_tasks + .annotate(count=Count('id')) + .values('count')[:1] + ) + annotations.update({ + 'discord_xp_synced_at': Subquery( current_xp_queryset.values('synced_at')[:1], output_field=DateTimeField(), ), - current_xp_row_id=Subquery( + 'current_xp_row_id': Subquery( current_xp_queryset.values('id')[:1], output_field=IntegerField(), ), - pending_portal_points=Coalesce( - Subquery(pending_points_queryset, output_field=IntegerField()), - Value(0), - output_field=IntegerField(), - ), - pending_social_task_points=Coalesce( - Subquery(pending_social_task_points_queryset, output_field=IntegerField()), - Value(0), - output_field=IntegerField(), - ), - tracked_portal_points_all_time=Coalesce( + 'tracked_portal_points_all_time': Coalesce( Subquery(all_time_points_queryset, output_field=IntegerField()), Value(0), output_field=IntegerField(), ), - community_contribution_count=Coalesce( + 'community_contribution_count': Coalesce( Subquery(contribution_count_queryset, output_field=IntegerField()), Value(0), output_field=IntegerField(), ), - tracked_social_task_points_all_time=Coalesce( + 'tracked_social_task_points_all_time': Coalesce( Subquery(all_time_social_task_points_queryset, output_field=IntegerField()), Value(0), output_field=IntegerField(), ), - community_social_task_count=Coalesce( + 'community_social_task_count': Coalesce( Subquery(social_task_count_queryset, output_field=IntegerField()), Value(0), output_field=IntegerField(), ), - latest_applied_sync_completed_at=Value( + 'latest_applied_sync_completed_at': Value( latest_sync.completed_at if latest_sync else None, output_field=DateTimeField(), ), - latest_applied_at=Value( + 'latest_applied_at': Value( latest_sync.applied_at if latest_sync else None, output_field=DateTimeField(), ), - ) + }) + selected_fields = ('id', 'name', 'address', 'profile_image_url', 'visible') + + queryset = ( + user_queryset + .only(*selected_fields) + .annotate(**annotations) .annotate( total_points=( F('discord_xp') + F('pending_portal_points') + F('pending_social_task_points') ), + community_sort_name=Lower(Coalesce('name', Value(''))), + ) + ) + + if include_details: + queryset = queryset.annotate( has_discord_xp_snapshot=Case( When(current_xp_row_id__isnull=False, then=Value(True)), default=Value(False), output_field=BooleanField(), ), - community_sort_name=Lower(Coalesce('name', Value(''))), ) + + return queryset + + +def build_effective_community_scores_queryset(user_ids=None, guild_id=None, visible_only=True): + """ + Return users annotated with the full effective community score breakdown. + + Effective points are MEE6 current XP plus contribution and social-task + points not covered by the applied MEE6 baseline. + """ + return _build_effective_community_scores_queryset( + user_ids=user_ids, + guild_id=guild_id, + visible_only=visible_only, + include_details=True, + ) + + +def build_effective_community_ranking_queryset( + user_ids=None, + guild_id=None, + visible_only=True, +): + """Return users annotated only with fields needed to rank community scores.""" + return _build_effective_community_scores_queryset( + user_ids=user_ids, + guild_id=guild_id, + visible_only=visible_only, + include_details=False, ) def effective_community_ranking_queryset(user_ids=None, guild_id=None, visible_only=True): return ( - build_effective_community_scores_queryset( + build_effective_community_ranking_queryset( user_ids=user_ids, guild_id=guild_id, visible_only=visible_only, @@ -307,47 +348,57 @@ def get_community_member_user_ids(user_ids=None, guild_id=None, visible_only=Tru from creators.models import Creator from poaps.models import PoapClaim - score_queryset = effective_community_ranking_queryset( - user_ids=user_ids, + guild_id = str(guild_id or get_default_guild_id()) + latest_sync = get_latest_applied_sync(guild_id) + + positive_xp = Mee6CurrentXP.objects.filter( guild_id=guild_id, - visible_only=visible_only, + matched_user__isnull=False, + xp__gt=0, + ) + pending_social_tasks = ( + _community_social_task_completions(user_ids=user_ids) + .annotate(pending_points=_pending_points_case( + 'points_awarded', + 'discord_xp_state', + latest_sync.completed_at if latest_sync else None, + )) + .filter(pending_points__gt=0) + ) + if visible_only: + positive_xp = positive_xp.filter(matched_user__visible=True) + pending_social_tasks = pending_social_tasks.filter(user__visible=True) + if user_ids is not None: + positive_xp = positive_xp.filter(matched_user_id__in=user_ids) + + eligible_member_user_ids = set( + positive_xp.values_list('matched_user_id', flat=True).distinct() ) - score_member_user_ids = set( - score_queryset - .filter(Q(discord_xp__gt=0) | Q(pending_social_task_points__gt=0)) - .values_list('id', flat=True) + eligible_member_user_ids.update( + pending_social_tasks.values_list('user_id', flat=True).distinct() ) + member_contributions = _community_member_contributions(user_ids=user_ids) if visible_only: member_contributions = member_contributions.filter(user__visible=True) - score_member_user_ids.update( + eligible_member_user_ids.update( member_contributions .values_list('user_id', flat=True) .distinct() ) - member_user_ids = set(score_member_user_ids if since is None else []) + member_user_ids = set(eligible_member_user_ids if since is None else []) - contribution_filters = { - 'contribution_type__category__slug': 'community', - 'contribution_type__is_submittable': True, - } poap_filters = { 'user__isnull': False, } if visible_only: - contribution_filters['user__visible'] = True poap_filters['user__visible'] = True if user_ids is not None: - contribution_filters['user_id__in'] = user_ids poap_filters['user_id__in'] = user_ids if since is not None: - contribution_filters['created_at__gte'] = since poap_filters['created_at__gte'] = since - recent_effective_contributions = _community_member_contributions(user_ids=user_ids) - if visible_only: - recent_effective_contributions = recent_effective_contributions.filter(user__visible=True) member_user_ids.update( - recent_effective_contributions + member_contributions .filter(created_at__gte=since) .values_list('user_id', flat=True) .distinct() @@ -362,7 +413,7 @@ def get_community_member_user_ids(user_ids=None, guild_id=None, visible_only=Tru .distinct() ) creator_filters = { - 'user_id__in': score_member_user_ids, + 'user_id__in': eligible_member_user_ids, 'created_at__gte': since, } if visible_only: @@ -374,13 +425,6 @@ def get_community_member_user_ids(user_ids=None, guild_id=None, visible_only=Tru .distinct() ) - member_user_ids.update( - Contribution.objects - .filter(**contribution_filters) - .exclude(contribution_type__slug__in=COMMUNITY_MEMBER_EXCLUDED_TYPE_SLUGS) - .values_list('user_id', flat=True) - .distinct() - ) member_user_ids.update( PoapClaim.objects .filter(**poap_filters) diff --git a/backend/leaderboard/tests/test_community_search.py b/backend/leaderboard/tests/test_community_search.py index 60f54e94..8aab3114 100644 --- a/backend/leaderboard/tests/test_community_search.py +++ b/backend/leaderboard/tests/test_community_search.py @@ -1,7 +1,12 @@ +from unittest.mock import patch + +from django.db import connection from django.test import TestCase +from django.test.utils import CaptureQueriesContext from django.utils import timezone from rest_framework.test import APIClient +from community_xp.utils import build_effective_community_scores_queryset from contributions.models import Category, Contribution, ContributionType from creators.models import Creator from leaderboard.models import GlobalLeaderboardMultiplier @@ -37,6 +42,7 @@ def setUp(self): }, ) + self.ranked_users = {} for index, (name, points) in enumerate([ ('Alice', 9000), ('Bob', 6000), @@ -48,6 +54,7 @@ def setUp(self): address=f'0x{str(index) * 40}', name=name, ) + self.ranked_users[name] = user Contribution.objects.create( user=user, contribution_type=community_type, @@ -56,6 +63,22 @@ def setUp(self): contribution_date=timezone.now() ) + def create_ranked_user(self, *, name, points, suffix): + user = User.objects.create_user( + email=f'community-extra-{suffix}@example.com', + password='pass', + address=f'0x{suffix:040x}', + name=name, + ) + Contribution.objects.create( + user=user, + contribution_type=ContributionType.objects.get(slug='community-post'), + points=points, + frozen_global_points=points, + contribution_date=timezone.now(), + ) + return user + def test_list_without_search_ranks_sequentially(self): response = self.client.get('/api/v1/leaderboard/community/') self.assertEqual(response.status_code, 200) @@ -63,9 +86,26 @@ def test_list_without_search_ranks_sequentially(self): self.assertEqual([r['user_name'] for r in results], ['Alice', 'Bob', 'Carol']) self.assertEqual([r['rank'] for r in results], [1, 2, 3]) + def test_negative_offset_is_clamped_to_first_page(self): + response = self.client.get( + '/api/v1/leaderboard/community/', + {'limit': 2, 'offset': -1}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [row['user_name'] for row in response.data['results']], + ['Alice', 'Bob'], + ) + self.assertEqual( + [row['rank'] for row in response.data['results']], + [1, 2], + ) + def test_search_keeps_true_rank(self): response = self.client.get('/api/v1/leaderboard/community/', {'search': 'carol'}) self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 1) results = response.data['results'] self.assertEqual(len(results), 1) self.assertEqual(results[0]['user_name'], 'Carol') @@ -79,6 +119,144 @@ def test_user_rank_ignores_search_filter(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['user_rank'], 2) + def test_numeric_user_lookup_keeps_true_rank(self): + carol = self.ranked_users['Carol'] + + response = self.client.get( + '/api/v1/leaderboard/community/', + {'user_address': str(carol.id)}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['user_rank'], 3) + self.assertEqual(response.data['user_total_points'], 3000) + + def test_ties_floor_and_pagination_preserve_public_ranks(self): + tie_before_bob = self.create_ranked_user( + name='Aaron Tie', + points=6000, + suffix=10, + ) + tie_after_bob = self.create_ranked_user( + name='Bob', + points=6000, + suffix=11, + ) + floor_user = self.create_ranked_user( + name='Floor', + points=2500, + suffix=12, + ) + below_floor = self.create_ranked_user( + name='Below Floor', + points=2499, + suffix=13, + ) + + response = self.client.get( + '/api/v1/leaderboard/community/', + {'limit': 3, 'offset': 1}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 6) + results = response.data['results'] + self.assertEqual( + [row['id'] for row in results], + [ + tie_before_bob.id, + self.ranked_users['Bob'].id, + tie_after_bob.id, + ], + ) + self.assertEqual([row['rank'] for row in results], [2, 3, 4]) + + all_response = self.client.get('/api/v1/leaderboard/community/') + self.assertEqual(all_response.data['results'][-1]['id'], floor_user.id) + self.assertNotIn( + below_floor.id, + [row['id'] for row in all_response.data['results']], + ) + + def test_full_detail_scoring_query_runs_once_for_selected_page(self): + bob = self.ranked_users['Bob'] + + with patch( + 'community_xp.utils.build_effective_community_scores_queryset', + wraps=build_effective_community_scores_queryset, + ) as full_detail_builder: + with CaptureQueriesContext(connection) as queries: + response = self.client.get( + '/api/v1/leaderboard/community/', + {'limit': 1, 'offset': 1}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual([row['id'] for row in response.data['results']], [bob.id]) + full_detail_builder.assert_called_once_with( + user_ids=[bob.id], + visible_only=True, + ) + detail_queries = [ + query['sql'] for query in queries + if '"tracked_portal_points_all_time"' in query['sql'] + ] + self.assertEqual(len(detail_queries), 1) + self.assertIn( + f'"users_user"."id" IN ({bob.id})', + detail_queries[0], + ) + + def test_hydration_skips_user_hidden_after_ranking_snapshot(self): + bob = self.ranked_users['Bob'] + carol = self.ranked_users['Carol'] + + def hide_bob_before_hydration(*, user_ids, visible_only): + User.objects.filter(id=bob.id).update(visible=False) + return build_effective_community_scores_queryset( + user_ids=user_ids, + visible_only=visible_only, + ) + + with patch( + 'community_xp.utils.build_effective_community_scores_queryset', + side_effect=hide_bob_before_hydration, + ): + page_response = self.client.get( + '/api/v1/leaderboard/community/', + {'limit': 1, 'offset': 1}, + ) + + self.assertEqual(page_response.status_code, 200) + self.assertEqual(page_response.data['count'], 3) + self.assertEqual(page_response.data['results'], []) + + User.objects.filter(id=bob.id).update(visible=True) + with patch( + 'community_xp.utils.build_effective_community_scores_queryset', + side_effect=hide_bob_before_hydration, + ): + context_response = self.client.get( + '/api/v1/leaderboard/community/', + { + 'user_address': carol.address, + 'profile_context': 'true', + }, + ) + + self.assertEqual(context_response.status_code, 200) + self.assertEqual(context_response.data['count'], 3) + self.assertEqual(context_response.data['user_rank'], 3) + self.assertEqual(context_response.data['top_entry']['user_name'], 'Alice') + self.assertEqual( + [row['user_name'] for row in context_response.data['context_results']], + ['Carol'], + ) + self.assertEqual( + [row['rank'] for row in context_response.data['context_results']], + [3], + ) + def test_profile_context_ignores_search_filter(self): response = self.client.get( '/api/v1/leaderboard/community/', diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index be99569a..02f0830e 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -830,8 +830,10 @@ def community(self, request): Returns users sorted by effective community points. Supports limit/offset pagination and user_address lookup. """ + from users.models import User from users.serializers import LightUserSerializer from community_xp.utils import ( + build_effective_community_ranking_queryset, build_effective_community_scores_queryset, get_community_member_user_ids, ) @@ -846,51 +848,55 @@ def community(self, request): offset = int(request.query_params.get('offset', 0)) except (ValueError, TypeError): offset = 0 + offset = max(offset, 0) member_user_ids = get_community_member_user_ids(visible_only=True) - entries = ( - build_effective_community_scores_queryset( + ranking_snapshot = list( + build_effective_community_ranking_queryset( user_ids=member_user_ids, visible_only=True, ) .filter(total_points__gte=COMMUNITY_RANKING_MIN_POINTS) .order_by('-total_points', 'community_sort_name', 'id') + .values_list('id', 'total_points') ) - # Full public ranking, kept unfiltered by search so search results keep - # their true ranks within the eligible leaderboard surface. - ranking_entries = entries + ranked_user_ids = [user_id for user_id, _ in ranking_snapshot] + rank_by_user_id = { + user_id: rank + for rank, (user_id, _) in enumerate(ranking_snapshot, start=1) + } + position_by_user_id = { + user_id: position + for position, user_id in enumerate(ranked_user_ids) + } + total_points_by_user_id = dict(ranking_snapshot) search = request.query_params.get('search', '').strip().lower() + filtered_user_ids = ranked_user_ids if search: address_q = ( Q(address__iexact=search) if is_full_address(search) else Q() ) - entries = entries.filter(Q(name__icontains=search) | address_q) + search_user_ids = set( + User.objects + .filter(id__in=ranked_user_ids, visible=True) + .filter(Q(name__icontains=search) | address_q) + .values_list('id', flat=True) + ) + filtered_user_ids = [ + user_id for user_id in ranked_user_ids + if user_id in search_user_ids + ] - count = entries.count() + count = len(filtered_user_ids) user_address = request.query_params.get('user_address') + user_id = None user_rank = None user_total_points = None - def community_rank(user): - total_points = user.total_points or 0 - sort_name = user.community_sort_name or '' - return ranking_entries.filter( - Q(total_points__gt=total_points) | - Q( - total_points=total_points, - community_sort_name__lt=sort_name, - ) | - Q( - total_points=total_points, - community_sort_name=sort_name, - id__lt=user.id, - ) - ).count() + 1 - - def serialize_community_user(user, rank): + def serialize_community_user(user): user_data = LightUserSerializer(user).data - total_points = user.total_points or 0 + total_points = total_points_by_user_id[user.id] return { **user_data, 'user_details': user_data, @@ -908,29 +914,54 @@ def serialize_community_user(user, rank): 'has_discord_xp_snapshot': user.has_discord_xp_snapshot, 'latest_applied_sync_completed_at': user.latest_applied_sync_completed_at, 'latest_applied_at': user.latest_applied_at, - 'rank': rank, + 'rank': rank_by_user_id[user.id], + } + + def get_full_details(user_ids): + if not user_ids: + return {} + return { + user.id: user + for user in build_effective_community_scores_queryset( + user_ids=user_ids, + visible_only=True, + ) } if user_address: - user_entry = ranking_entries.filter( - **user_lookup_kwargs(user_address) - ).first() - if user_entry: - user_total_points = user_entry.total_points or 0 - user_rank = community_rank(user_entry) + user_id = ( + User.objects + .filter(visible=True, **user_lookup_kwargs(user_address)) + .values_list('id', flat=True) + .first() + ) + if user_id in rank_by_user_id: + user_total_points = total_points_by_user_id[user_id] + user_rank = rank_by_user_id[user_id] if request.query_params.get('profile_context') in ('1', 'true', 'True', 'yes'): - top_user = ranking_entries.first() - top_entry = serialize_community_user(top_user, 1) if top_user else None - context_results = [] + top_user_id = ranked_user_ids[0] if ranked_user_ids else None + context_user_ids = [] if user_rank: - context_offset = max(user_rank - 2, 0) - context_page = ranking_entries[context_offset:context_offset + 3] - context_results = [ - serialize_community_user(user, rank) - for rank, user in enumerate(context_page, start=context_offset + 1) - ] + user_position = position_by_user_id[user_id] + context_offset = max(user_position - 1, 0) + context_user_ids = ranked_user_ids[context_offset:context_offset + 3] + + detail_user_ids = list(dict.fromkeys( + ([top_user_id] if top_user_id is not None else []) + context_user_ids + )) + details_by_user_id = get_full_details(detail_user_ids) + top_user = details_by_user_id.get(top_user_id) + top_entry = ( + serialize_community_user(top_user) + if top_user is not None else None + ) + context_results = [ + serialize_community_user(details_by_user_id[context_user_id]) + for context_user_id in context_user_ids + if context_user_id in details_by_user_id + ] return Response({ 'total_community': count, @@ -941,12 +972,13 @@ def serialize_community_user(user, rank): 'user_total_points': user_total_points, }) - page = entries[offset:offset + limit] - - results = [] - for index, user in enumerate(page, start=offset + 1): - rank = community_rank(user) if search else index - results.append(serialize_community_user(user, rank)) + page_user_ids = filtered_user_ids[offset:offset + limit] + details_by_user_id = get_full_details(page_user_ids) + results = [ + serialize_community_user(details_by_user_id[page_user_id]) + for page_user_id in page_user_ids + if page_user_id in details_by_user_id + ] response_data = { 'total_community': count, diff --git a/backend/tally/settings.py b/backend/tally/settings.py index d5eaf604..a2e0839c 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -432,6 +432,8 @@ def get_port_from_argv(): # Blockchain settings # Shared RPC URL for all networks VALIDATOR_RPC_URL = get_required_env('VALIDATOR_RPC_URL') +WEB3_RPC_TIMEOUT_SECONDS = int(os.environ.get('WEB3_RPC_TIMEOUT_SECONDS', '10') or '10') +WEB3_RPC_MAX_RETRIES = int(os.environ.get('WEB3_RPC_MAX_RETRIES', '1') or '1') # Legacy settings (backward compatibility - deprecated, use TESTNET_NETWORKS instead) VALIDATOR_CONTRACT_ADDRESS = os.environ.get( diff --git a/backend/users/tests/test_validators_endpoint.py b/backend/users/tests/test_validators_endpoint.py new file mode 100644 index 00000000..1ae039ba --- /dev/null +++ b/backend/users/tests/test_validators_endpoint.py @@ -0,0 +1,42 @@ +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APITestCase + + +User = get_user_model() + + +class UserValidatorsEndpointTests(APITestCase): + def setUp(self): + user = User.objects.create_user( + email='validator-reader@example.com', + password='testpass123', + ) + self.client.force_authenticate(user=user) + + @patch('users.views.UserViewSet._get_web3_contract') + def test_success_response_still_filters_zero_addresses(self, get_contract): + valid_address = '0x1111111111111111111111111111111111111111' + contract = MagicMock() + contract.functions.getValidatorsAtCurrentEpoch.return_value.call.return_value = [ + valid_address, + '0x0000000000000000000000000000000000000000', + ] + get_contract.return_value = contract + + response = self.client.get('/api/v1/users/validators/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, [valid_address]) + + @patch( + 'users.views.UserViewSet._get_web3_contract', + side_effect=RuntimeError('rpc unavailable'), + ) + def test_failure_response_is_unchanged(self, _get_contract): + response = self.client.get('/api/v1/users/validators/') + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.data, {'error': 'rpc unavailable'}) diff --git a/backend/users/views.py b/backend/users/views.py index b0e9bbe4..fe7a3bb3 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -20,6 +20,7 @@ from leaderboard.models import LeaderboardEntry from poaps.views import UserPoapMixin from web3 import Web3 +from utils.web3_provider import build_web3_http_provider import secrets import string @@ -364,7 +365,7 @@ def _get_web3_contract(self): Helper method to create a Web3 contract instance """ # Connect to the blockchain using environment variables - w3 = Web3(Web3.HTTPProvider(settings.VALIDATOR_RPC_URL)) + w3 = Web3(build_web3_http_provider(settings.VALIDATOR_RPC_URL)) # Contract address from network config (Asimov - this endpoint is Asimov-only) contract_address = settings.TESTNET_NETWORKS['asimov']['staking_contract_address'] diff --git a/backend/utils/tests/test_web3_provider.py b/backend/utils/tests/test_web3_provider.py new file mode 100644 index 00000000..0d4eabf8 --- /dev/null +++ b/backend/utils/tests/test_web3_provider.py @@ -0,0 +1,73 @@ +from unittest.mock import patch + +import requests +from django.conf import settings +from django.test import SimpleTestCase, override_settings + +from users.views import UserViewSet +from utils.web3_provider import build_web3_http_provider +from validators.genlayer_validators_service import GenLayerValidatorsService + + +class Web3HTTPProviderTests(SimpleTestCase): + def test_default_rpc_boundaries(self): + self.assertEqual(settings.WEB3_RPC_TIMEOUT_SECONDS, 10) + self.assertEqual(settings.WEB3_RPC_MAX_RETRIES, 1) + + @override_settings(WEB3_RPC_TIMEOUT_SECONDS=4, WEB3_RPC_MAX_RETRIES=2) + def test_provider_applies_timeout_and_retry_budget(self): + provider = build_web3_http_provider('https://rpc.example') + + self.assertEqual(provider.get_request_kwargs()['timeout'], 4) + # Web3.py 7.16's loop value is total attempts: initial + two retries. + self.assertEqual(provider.exception_retry_configuration.retries, 3) + + @override_settings(WEB3_RPC_TIMEOUT_SECONDS=10, WEB3_RPC_MAX_RETRIES=1) + def test_one_retry_occurs_after_initial_timeout(self): + provider = build_web3_http_provider('https://rpc.example') + response = b'{"jsonrpc":"2.0","id":0,"result":"0x1"}' + + with ( + patch.object( + provider._request_session_manager, + 'make_post_request', + side_effect=[requests.Timeout('timed out'), response], + ) as make_post_request, + patch('web3.providers.rpc.rpc.time.sleep'), + ): + result = provider.make_request('eth_call', []) + + self.assertEqual(result['result'], '0x1') + self.assertEqual(make_post_request.call_count, 2) + + @override_settings(WEB3_RPC_TIMEOUT_SECONDS=10, WEB3_RPC_MAX_RETRIES=1) + def test_one_retry_occurs_after_initial_connection_error(self): + provider = build_web3_http_provider('https://rpc.example') + response = b'{"jsonrpc":"2.0","id":0,"result":"0x1"}' + + with ( + patch.object( + provider._request_session_manager, + 'make_post_request', + side_effect=[requests.ConnectionError('connection failed'), response], + ) as make_post_request, + patch('web3.providers.rpc.rpc.time.sleep'), + ): + result = provider.make_request('eth_call', []) + + self.assertEqual(result['result'], '0x1') + self.assertEqual(make_post_request.call_count, 2) + + @override_settings(WEB3_RPC_TIMEOUT_SECONDS=6, WEB3_RPC_MAX_RETRIES=1) + def test_validator_sync_uses_bounded_provider(self): + service = GenLayerValidatorsService(network_key='asimov') + + self.assertEqual(service.w3.provider.get_request_kwargs()['timeout'], 6) + self.assertEqual(service.w3.provider.exception_retry_configuration.retries, 2) + + @override_settings(WEB3_RPC_TIMEOUT_SECONDS=7, WEB3_RPC_MAX_RETRIES=1) + def test_users_validators_contract_uses_bounded_provider(self): + contract = UserViewSet()._get_web3_contract() + + self.assertEqual(contract.w3.provider.get_request_kwargs()['timeout'], 7) + self.assertEqual(contract.w3.provider.exception_retry_configuration.retries, 2) diff --git a/backend/utils/web3_provider.py b/backend/utils/web3_provider.py new file mode 100644 index 00000000..d4afd44e --- /dev/null +++ b/backend/utils/web3_provider.py @@ -0,0 +1,29 @@ +import requests +from django.conf import settings +from web3 import HTTPProvider +from web3.providers.rpc.utils import ExceptionRetryConfiguration + + +RETRYABLE_HTTP_EXCEPTIONS = ( + requests.ConnectionError, + requests.HTTPError, + requests.Timeout, +) + + +def build_web3_http_provider(endpoint_uri): + """Build the bounded HTTP provider used for validator RPC reads.""" + max_retries = max(0, settings.WEB3_RPC_MAX_RETRIES) + + # Web3.py 7.16 counts the initial request in its `retries` loop, so add one + # to keep this setting's meaning as retries after the initial attempt. + retry_configuration = ExceptionRetryConfiguration( + errors=RETRYABLE_HTTP_EXCEPTIONS, + retries=max_retries + 1, + ) + + return HTTPProvider( + endpoint_uri=endpoint_uri, + request_kwargs={'timeout': settings.WEB3_RPC_TIMEOUT_SECONDS}, + exception_retry_configuration=retry_configuration, + ) diff --git a/backend/validators/genlayer_validators_service.py b/backend/validators/genlayer_validators_service.py index cd9788e3..9fc69be8 100644 --- a/backend/validators/genlayer_validators_service.py +++ b/backend/validators/genlayer_validators_service.py @@ -10,6 +10,7 @@ from tally.middleware.logging_utils import get_app_logger from tally.middleware.tracing import trace_external +from utils.web3_provider import build_web3_http_provider logger = get_app_logger('validators') @@ -163,7 +164,7 @@ def _initialize_client(self): """Initialize Web3 client and contract instances.""" try: rpc_url = self.network_config.get('rpc_url') or settings.VALIDATOR_RPC_URL - self.w3 = Web3(Web3.HTTPProvider(rpc_url)) + self.w3 = Web3(build_web3_http_provider(rpc_url)) staking_address = self.network_config.get('staking_contract_address') if not staking_address: diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index a076b566..2a9d663a 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -294,7 +294,7 @@ frontend/src/ - **Notifications**: `src/components/NotificationCenter.svelte` - Bell icon button in the navbar, left of the search bar on desktop, before the auth button on mobile; only when authenticated - Unread badge, dropdown with latest notifications, mark-all-read, "View all" linking to `/notifications` - - Polls unread count every 60s; clicking a notification marks it read (non-blocking) and follows its `link_url` (internal routes push in-app, http(s) opens a new tab) + - Polls unread count every 60s while the tab is visible; the desktop/mobile instances share one refcounted timer and visibility listener, and returning to the tab refreshes immediately. Clicking a notification marks it read (non-blocking) and follows its `link_url` (internal routes push in-app, http(s) opens a new tab) - Full feed page: `src/routes/Notifications.svelte` (All/Unread filter pills, load-more pagination). Bodies render as sanitized image-free markdown via `parseUserMarkdown()` (no ``, so private campaign opens can't ping external tracking pixels); rows are `div[role=button]` so markdown links stay clickable, inline anchor clicks don't also trigger the row's `link_url` redirect, and rows without a `link_url` show a default cursor (pure announcements) - Shared utils: `src/lib/notificationUtils.js` (`asList` payload normalization, `followNotificationLink` link handling) and `src/lib/relativeTime.js` for compact timestamps - **Sidebar**: `src/components/Sidebar.svelte` @@ -319,7 +319,7 @@ The portal uses **history-based routing** (clean `/testnets` URLs, not hash `/#/ - **Links:** plain `` is SPA-navigated automatically by a global click interceptor (`installLinkInterceptor`, installed once in `App.svelte`); it skips modified/new-tab/external/file links and any anchor whose own handler already called `preventDefault()`. Never write `href="#/..."`. - **Deep links / refresh:** AWS Amplify (`amplify.yml` customRules) and the Vite dev server already serve `index.html` for unmatched paths — no server change needed. - **Back-compat:** a tiny boot script in `index.html` rewrites any incoming legacy `#/path` to `/path` before the app mounts, so old shared hash links still resolve. -- **Guard error semantics (degraded backend):** "couldn't verify" is never treated as "not a member". `performVerification` (auth.js) only sets unauthenticated on a definitive <500 response — network/5xx keeps the current state and leaves `hasVerified` unset so it retries. `userStore.loadUser()` only clears `user` on 401/403; other failures keep the previously loaded user. `requireRoleForRoute` (App.svelte) fails OPEN when `/users/me/` fails with a non-auth error (the backend enforces real permissions on every API call). Journey pages (`CommunityJourney`, `BuilderJourney`) only auto-call the journey-start endpoint when a user object is actually loaded, so an outage can't fire a journey-start mutation for an existing member. +- **Guard error semantics (degraded backend):** "couldn't verify" is never treated as "not a member". `performVerification` (auth.js) only sets unauthenticated on a definitive <500 response — network/5xx keeps the current state and leaves `hasVerified` unset so it retries. `userStore.loadUser()` only clears `user` on 401/403; other failures keep the previously loaded user. `requireRoleForRoute` (App.svelte) fails OPEN when `/users/me/` fails with a non-auth error (the backend enforces real permissions on every API call). `CommunityJourneyGate.svelte` is stricter for `/community/journey`: it refreshes `/users/me/`, redirects confirmed Creators, and renders Retry without mounting journey code when verification fails. Journey pages only auto-call journey endpoints for a loaded, eligible user. - **Static OG:** `scripts/generate-og-pages.mjs` (post-build) writes `dist//index.html` per `STATIC_OG_ROUTES` with route-specific meta; with history routing a copied static-route URL hits that prerendered file directly. Dynamic detail pages (projects/POAPs/profiles) still serve the generic card to crawlers — a future backend-meta + edge-function task. ### Routes/Pages @@ -341,6 +341,7 @@ const routes = { '/participants': Validators, '/referrals': Referrals, '/community': ReferralProgram, + '/community/journey': CommunityJourneyGate, // Refreshes profile before mounting the Creator journey '/community/contributions': Contributions, '/community/all-contributions': AllContributions, '/community/contributions/highlights': AllContributions, @@ -423,10 +424,11 @@ const routes = { #### Community POAPs - **`/community/poaps`** - POAP collection wall (`CommunityPoaps.svelte`) - - Calls `poapsAPI.list({ page, page_size: 100, ordering: '-event_start_at', search?, month? })`. + - Calls `poapsAPI.list({ page, page_size: 48, ordering: '-event_start_at', search?, month? })`. - `loadPoaps(nextPage = 1, append = false)` replaces the list on initial/filter loads and appends only when `append=true`. - Search and month filters are applied when a non-append load starts; appended loads reuse `appliedSearch` / `appliedMonthFilter` so typed-but-unsubmitted filter changes do not mix result sets. - `loading` controls the initial/filter skeleton, `loadingMore` controls the Load more button, and `hasMore` is driven by the paginated API `next` field. + - Initial/filter failures render a Retry state; append failures keep the loaded collection visible and retry the same next page. - Overlapping list requests must be guarded with `latestPoapsRequestId` before mutating list, error, or loading state. - **`/community/poaps/recover`** - POAP recovery flow for attaching legacy wallet claims. - **`/community/poaps/:slug`** - POAP detail page with lazy collector loading. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index dc77b962..28f3520d 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -120,7 +120,7 @@ import SocialTasks from './routes/SocialTasks.svelte'; import RoleFunnel from './components/funnel/RoleFunnel.svelte'; import BuilderJourney from './routes/BuilderJourney.svelte'; - import CommunityJourney from './routes/CommunityJourney.svelte'; + import CommunityJourneyGate from './routes/CommunityJourneyGate.svelte'; async function requireAuthForRoute({ location, querystring }) { const state = authState.get(); @@ -243,7 +243,7 @@ '/participants': protectedRoute(Validators), '/referrals': protectedRoute(Referrals), '/community': RoleFunnel, - '/community/journey': protectedRoute(CommunityJourney), + '/community/journey': protectedRoute(CommunityJourneyGate), '/community/contributions': roleGatedRoute(Contributions, 'community'), '/community/all-contributions': roleGatedRoute(AllContributions, 'community'), '/community/referrals': LegacyReferralRedirect, diff --git a/frontend/src/lib/notificationStore.js b/frontend/src/lib/notificationStore.js index d38611ff..cef9a989 100644 --- a/frontend/src/lib/notificationStore.js +++ b/frontend/src/lib/notificationStore.js @@ -30,11 +30,30 @@ function createNotificationStore() { // changed (logout, wallet switch) are discarded instead of leaking the // previous account's feed into the new session. let epoch = 0; + // Every request that can write unreadCount claims a monotonically increasing + // version. This lets an immediate visibility refresh supersede count data + // already in flight through either loadUnreadCount() or loadLatest(). + let unreadWriteVersion = 0; + // Local read mutations supersede item lists that started loading before + // those mutations completed. + let itemWriteVersion = 0; + + function pollUnreadCountIfVisible() { + if (document.hidden || !authState.get().isAuthenticated) return; + loadUnreadCount(); + } + + function refreshUnreadCountOnVisibility() { + if (document.hidden || !authState.get().isAuthenticated) return; + loadUnreadCount({ force: true }); + } function loadLatest() { if (inflightLatest) return inflightLatest; const requestEpoch = epoch; + const requestUnreadVersion = ++unreadWriteVersion; + const requestItemVersion = itemWriteVersion; update((state) => ({ ...state, loading: true, error: null })); const request = Promise.all([ @@ -45,8 +64,12 @@ function createNotificationStore() { if (requestEpoch !== epoch) return; update((state) => ({ ...state, - items: asList(listResponse.data), - unreadCount: countResponse.data?.count || 0, + ...(requestItemVersion === itemWriteVersion + ? { items: asList(listResponse.data) } + : {}), + ...(requestUnreadVersion === unreadWriteVersion + ? { unreadCount: countResponse.data?.count || 0 } + : {}), loading: false })); }) @@ -62,18 +85,19 @@ function createNotificationStore() { return request; } - function loadUnreadCount() { - if (inflightCount) return inflightCount; + function loadUnreadCount({ force = false } = {}) { + if (!force && inflightCount) return inflightCount; const requestEpoch = epoch; + const requestUnreadVersion = ++unreadWriteVersion; const request = notificationsAPI .unreadCount() .then((response) => { - if (requestEpoch !== epoch) return; + if (requestEpoch !== epoch || requestUnreadVersion !== unreadWriteVersion) return; update((state) => ({ ...state, unreadCount: response.data?.count || 0 })); }) .catch((error) => { - if (requestEpoch !== epoch) return; + if (requestEpoch !== epoch || requestUnreadVersion !== unreadWriteVersion) return; update((state) => ({ ...state, error })); }) .finally(() => { @@ -86,45 +110,47 @@ function createNotificationStore() { function startPolling(intervalMs = 60000) { pollSubscribers += 1; - if (!pollHandle) { - pollHandle = window.setInterval(() => { - if (authState.get().isAuthenticated) { - loadUnreadCount(); - } - }, intervalMs); + if (pollSubscribers === 1) { + pollHandle = window.setInterval(pollUnreadCountIfVisible, intervalMs); + document.addEventListener('visibilitychange', refreshUnreadCountOnVisibility); } + let stopped = false; return function stopPolling() { + if (stopped) return; + stopped = true; pollSubscribers = Math.max(0, pollSubscribers - 1); - if (pollSubscribers === 0 && pollHandle) { - window.clearInterval(pollHandle); + if (pollSubscribers === 0) { + if (pollHandle !== null) window.clearInterval(pollHandle); pollHandle = null; + document.removeEventListener('visibilitychange', refreshUnreadCountOnVisibility); } }; } async function markRead(id) { + const requestEpoch = epoch; const response = await notificationsAPI.markRead(id); const updated = response.data; + if (requestEpoch !== epoch) return updated; - update((state) => { - // Items outside the dropdown slice (page-only) still decrement: their - // callers only mark unread items. For items in the slice, skip the - // decrement when they were already read (e.g. double-click replay). - const itemInSlice = state.items.find((item) => item.id === id); - const shouldDecrement = itemInSlice ? !itemInSlice.is_read : true; - return { - ...state, - items: state.items.map((item) => (item.id === id ? updated : item)), - unreadCount: shouldDecrement ? Math.max(0, state.unreadCount - 1) : state.unreadCount - }; - }); + itemWriteVersion += 1; + update((state) => ({ + ...state, + items: state.items.map((item) => (item.id === id ? updated : item)) + })); + await loadUnreadCount({ force: true }); return updated; } async function markAllRead() { + const requestEpoch = epoch; await notificationsAPI.markAllRead(); + if (requestEpoch !== epoch) return; + + unreadWriteVersion += 1; + itemWriteVersion += 1; update((state) => ({ ...state, items: state.items.map((item) => ({ ...item, is_read: true })), @@ -134,6 +160,7 @@ function createNotificationStore() { function reset() { epoch += 1; + unreadWriteVersion += 1; inflightLatest = null; inflightCount = null; set({ items: [], unreadCount: 0, loading: false, error: null }); diff --git a/frontend/src/routes/CommunityJourney.svelte b/frontend/src/routes/CommunityJourney.svelte index 7a7d05f7..4367e1cb 100644 --- a/frontend/src/routes/CommunityJourney.svelte +++ b/frontend/src/routes/CommunityJourney.svelte @@ -57,6 +57,8 @@ let lastJourneyViewKey = $state(''); let lastStepViewKey = $state(''); let lastJourneyExitKey = $state(''); + let mounted = $state(false); + let latestJourneyRequestId = 0; let user = $derived($userStore.user); let twitterConnection = $derived(user?.twitter_connection || null); @@ -124,20 +126,37 @@ const handlePageHide = () => trackJourneyExit('pagehide'); window.addEventListener('pagehide', handlePageHide); - loadJourney({ showLoading: true }); + mounted = true; return () => { + mounted = false; + latestJourneyRequestId += 1; window.removeEventListener('pagehide', handlePageHide); trackJourneyExit('route_leave'); }; }); + // The route gate verifies membership before this component mounts. Keep the + // same check here as a final boundary for direct component mounts and stale + // profile transitions so Creator accounts never read journey/task state. + let initialLoadStarted = false; + $effect(() => { + if (!mounted || !user) return; + if (user.creator) { + replace('/community'); + return; + } + if (initialLoadStarted) return; + initialLoadStarted = true; + loadJourney({ showLoading: true }); + }); + // Start the journey only once the user profile has actually loaded. The // route can mount before /users/me/ resolves, so this must be reactive (an // onMount check would permanently skip the start marker for direct visits), // and a null user (backend down) must never trigger the mutation. let journeyStartChecked = false; $effect(() => { - if (journeyStartChecked || !user) return; + if (journeyStartChecked || !user || user.creator) return; journeyStartChecked = true; if (user.has_community_welcome) return; journeyAPI @@ -265,6 +284,12 @@ } async function loadJourney({ showLoading = false } = {}) { + if (user?.creator) { + replace('/community'); + return; + } + + const requestId = ++latestJourneyRequestId; if (showLoading) loading = true; loadError = ''; try { @@ -272,18 +297,20 @@ journeyAPI.communityJourney(), socialTasksAPI.list({ category: 'community' }), ]); + if (requestId !== latestJourneyRequestId || user?.creator) return; journey = journeyRes.data; tasks = Array.isArray(tasksRes.data) ? tasksRes.data : []; const existingPostUrl = journeyRes.data?.steps?.x_post?.post_url || ''; if (existingPostUrl && !postUrl) postUrl = existingPostUrl; } catch (err) { + if (requestId !== latestJourneyRequestId || user?.creator) return; loadError = err.response?.data?.message || err.response?.data?.error || 'Could not load your creator journey.'; if (showLoading) { journey = null; tasks = []; } } finally { - if (showLoading) loading = false; + if (requestId === latestJourneyRequestId && showLoading) loading = false; } } @@ -507,33 +534,46 @@ role="community" title={welcomeTitle} message={welcomeMessage} - chips={welcomeChips} - alert={welcomeAlert} - /> - - -
- {#if loading} - {#each Array(TOTAL_STEPS) as _, i} - - {/each} - {:else} + {#if !loading && loadError && !journey} + + {:else} + + +
+ {#if loading} + {#each Array(TOTAL_STEPS) as _, i} + + {/each} + {:else}
- {#if complete} -
-
-

Creator journey complete

- Click to finish. + {#if complete} +
+
+

Creator journey complete

+ Click to finish. +
-
+ {/if} {/if} - {/if} -
+
+ {/if}
+ {:else if loadError} + {:else if poaps.length === 0}

No POAPs found

@@ -187,7 +213,18 @@
{:else} - {#if hasMore} + {#if loadMoreError} + + {:else if hasMore}