From 4a1a697d5189e7feaadd130205bb87aba0177f26 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Sun, 5 Jul 2026 13:11:18 +0200 Subject: [PATCH 1/5] Harden the platform against deploy races and request storms Database migrations and leaderboard rank updates now run under PostgreSQL advisory locks so concurrent App Runner instances can no longer race each other during deploys or contribution bursts; rank updates also iterate leaderboards in sorted order to keep lock acquisition deadlock-free. Deploys can skip startup migrations via a RUN_MIGRATIONS_ON_STARTUP switch. API page sizes are capped at 50, and the mission contribution_type filter rejects non-numeric values instead of erroring server-side. The frontend stops amplifying load: catalog-style endpoints (missions, contribution types, partners) are fetched through a bounded paginated helper instead of oversized page_size requests, metrics endpoints share a 30-second cache, session refresh / user load / what's-new refresh each collapse concurrent callers into one in-flight request, session refresh pauses in hidden tabs, and literal "undefined"/"null" query params are stripped before hitting the API. - backend/utils/management/commands/migrate_with_lock.py: New management command wrapping migrate in a pg_try_advisory_lock poll loop (timeout/poll configurable via flags or env); no-ops to plain migrate on non-Postgres. - backend/startup.sh: Uses migrate_with_lock and honors RUN_MIGRATIONS_ON_STARTUP (default true). - backend/deploy-apprunner.sh, backend/deploy-apprunner-dev.sh: Pass RUN_MIGRATIONS_ON_STARTUP through to App Runner env vars. - backend/github-actions-role-setup.sh: create-role is now idempotent (updates trust policy when the role exists). - backend/leaderboard/models.py: update_leaderboard_ranks takes a pg_advisory_xact_lock keyed by leaderboard type inside a transaction; callers iterate leaderboard types sorted; recalculate_all_leaderboards ranks all configured leaderboards instead of a hardcoded list. - backend/utils/pagination.py: max_page_size 100 -> 50. - backend/contributions/views.py: MissionViewSet validates contribution_type query param, returning 400 on junk like "undefined". - frontend/src/lib/api.js: Adds sanitizeRequestParams request interceptor, fetchSmallCatalogPages helper with getAllContributionTypes/getAllMissions/partnersAPI.listAll, and a shared 30s metrics request cache for overview + network-activity. - frontend/src/lib/auth.js: refreshSession single-flight; periodic refresh skips hidden tabs. - frontend/src/lib/userStore.js, frontend/src/lib/whatsNewStore.js, frontend/src/lib/missionsStore.js: loadUser/refresh single-flight; missions store uses getAllMissions. - frontend/src/components/*, frontend/src/routes/*: Swapped page_size=100/200/1000 requests for the bounded fetch-all helpers or page_size<=50. - backend/*/tests/*, frontend/src/tests/*: Updated for the 50 max page size and new helpers; new tests for migrate_with_lock, the param sanitizer, auth session refresh, and whats-new store dedup. --- backend/CLAUDE.md | 2 + backend/api/tests.py | 2 +- backend/community_xp/tests/test_mee6_sync.py | 2 +- .../contributions/tests/test_pagination.py | 14 +- .../tests/test_security_boundaries.py | 11 +- .../tests/test_submission_limits.py | 2 +- backend/contributions/views.py | 15 +- backend/deploy-apprunner-dev.sh | 4 + backend/deploy-apprunner.sh | 8 +- backend/github-actions-role-setup.sh | 19 +- backend/leaderboard/models.py | 43 ++++- .../leaderboard/tests/test_recalculation.py | 8 +- backend/leaderboard/tests/test_stats.py | 2 +- backend/partners/tests/test_partners.py | 2 +- backend/startup.sh | 13 +- backend/utils/management/__init__.py | 1 + backend/utils/management/commands/__init__.py | 1 + .../management/commands/migrate_with_lock.py | 173 ++++++++++++++++++ backend/utils/pagination.py | 2 +- backend/utils/tests/test_migrate_with_lock.py | 116 ++++++++++++ frontend/src/components/Missions.svelte | 9 +- frontend/src/components/Sidebar.svelte | 5 +- .../portal/PartnerLogoBanner.svelte | 4 +- frontend/src/lib/api.js | 143 ++++++++++++--- frontend/src/lib/auth.js | 27 ++- frontend/src/lib/missionsStore.js | 4 +- frontend/src/lib/userStore.js | 48 +++-- frontend/src/lib/whatsNewStore.js | 21 ++- frontend/src/routes/AllContributions.svelte | 4 +- frontend/src/routes/CommunityPoaps.svelte | 2 +- .../src/routes/ContributionTypeDetail.svelte | 4 +- frontend/src/routes/EcosystemPartners.svelte | 2 +- frontend/src/routes/Metrics.svelte | 6 +- frontend/src/routes/ProjectPageEditor.svelte | 2 +- frontend/src/routes/StewardDiscordXP.svelte | 4 +- frontend/src/routes/StewardSubmissions.svelte | 9 +- frontend/src/tests/apiClient.test.js | 134 ++++++++++++++ frontend/src/tests/authSession.test.js | 102 +++++++++++ frontend/src/tests/setupTests.js | 32 ++-- frontend/src/tests/userStore.test.js | 22 ++- frontend/src/tests/whatsNewStore.test.js | 40 ++++ 41 files changed, 931 insertions(+), 133 deletions(-) create mode 100644 backend/utils/management/__init__.py create mode 100644 backend/utils/management/commands/__init__.py create mode 100644 backend/utils/management/commands/migrate_with_lock.py create mode 100644 backend/utils/tests/test_migrate_with_lock.py create mode 100644 frontend/src/tests/apiClient.test.js create mode 100644 frontend/src/tests/authSession.test.js create mode 100644 frontend/src/tests/whatsNewStore.test.js diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 01deaa19..308eea06 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -341,6 +341,8 @@ backend/ - **Database**: SQLite by default, configured in settings.py - **Run migrations**: `python manage.py migrate` - **Create migrations**: `python manage.py makemigrations` +- **Deploy-safe migrations**: `python manage.py migrate_with_lock` (`utils/management/commands/`) wraps migrate in a PostgreSQL advisory lock so concurrent App Runner instances don't race; `startup.sh` uses it and honors `RUN_MIGRATIONS_ON_STARTUP` (default true). No-ops to plain migrate on non-Postgres. +- **Pagination cap**: `utils/pagination.py` `SafePageNumberPagination.max_page_size = 50`; the frontend fetches larger catalogs via paginated fetch-all helpers (`getAllContributionTypes` / `getAllMissions` / `partnersAPI.listAll` in `frontend/src/lib/api.js`), never with oversized `page_size`. Leaderboard rank updates (`leaderboard/models.py`) take a per-type `pg_advisory_xact_lock` and callers iterate types sorted to keep lock order deadlock-free. ### Database Migration from Production - **Script**: `backend/scripts/migrate-prod-to-dev.sh` diff --git a/backend/api/tests.py b/backend/api/tests.py index 0ecce724..2a627b00 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -107,7 +107,7 @@ def _create_current_mee6_xp(self, user, guild_id, discord_id, xp, synced_at): guild_id=guild_id, guild_name=f'Guild {guild_id}', status=Mee6SyncRun.STATUS_SUCCESS, - page_size=1000, + page_size=50, pages_fetched=1, players_fetched=1, matched_players=1, diff --git a/backend/community_xp/tests/test_mee6_sync.py b/backend/community_xp/tests/test_mee6_sync.py index a8c14c6c..7d256e08 100644 --- a/backend/community_xp/tests/test_mee6_sync.py +++ b/backend/community_xp/tests/test_mee6_sync.py @@ -588,7 +588,7 @@ def test_fetch_and_apply_helper_applies_fetched_snapshot(self, run_mee6_sync_moc run = Mee6SyncRun.objects.create( guild_id='guild-1', - page_size=1000, + page_size=50, status=Mee6SyncRun.STATUS_SUCCESS, completed_at=timezone.now(), ) diff --git a/backend/contributions/tests/test_pagination.py b/backend/contributions/tests/test_pagination.py index 6ee776c4..fa03e648 100644 --- a/backend/contributions/tests/test_pagination.py +++ b/backend/contributions/tests/test_pagination.py @@ -122,10 +122,18 @@ def test_pagination_with_state_filter(self): def test_pagination_max_page_size(self): """Test that page_size is limited to max_page_size""" + for i in range(30): + SubmittedContribution.objects.create( + user=self.user, + contribution_type=self.contribution_type, + contribution_date=date.today(), + notes=f'Extra submission {i+1}', + state='pending' + ) + response = self.client.get('/api/v1/steward-submissions/?page_size=200') self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() - self.assertEqual(data['count'], 37) - # Should be limited to max_page_size (100) - self.assertEqual(len(data['results']), 37) # All 37 items fit within max of 100 + self.assertEqual(data['count'], 67) + self.assertEqual(len(data['results']), 50) diff --git a/backend/contributions/tests/test_security_boundaries.py b/backend/contributions/tests/test_security_boundaries.py index ffb067ad..1cc7fb49 100644 --- a/backend/contributions/tests/test_security_boundaries.py +++ b/backend/contributions/tests/test_security_boundaries.py @@ -332,11 +332,18 @@ def _listed_ids(self, params=None): return {item['id'] for item in results} def test_anonymous_list_hides_unstarted_missions(self): - ids = self._listed_ids({'include_inactive': '1', 'page_size': 100}) + ids = self._listed_ids({'include_inactive': '1', 'page_size': 50}) self.assertIn(self.active_mission.id, ids) self.assertIn(self.expired_mission.id, ids) # historical filters need these self.assertNotIn(self.unstarted_mission.id, ids) + def test_invalid_contribution_type_filter_returns_400(self): + response = self.client.get('/api/v1/missions/', { + 'contribution_type': 'undefined', + }) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_anonymous_cannot_retrieve_unstarted_mission(self): response = self.client.get(f'/api/v1/missions/{self.unstarted_mission.id}/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -353,7 +360,7 @@ def test_anonymous_can_still_access_expired_mission_detail_and_stats(self): def test_steward_sees_unstarted_missions(self): self.client.force_authenticate(user=self.steward_user) - ids = self._listed_ids({'include_inactive': '1', 'page_size': 100}) + ids = self._listed_ids({'include_inactive': '1', 'page_size': 50}) self.assertIn(self.unstarted_mission.id, ids) response = self.client.get(f'/api/v1/missions/{self.unstarted_mission.id}/') diff --git a/backend/contributions/tests/test_submission_limits.py b/backend/contributions/tests/test_submission_limits.py index 598ca751..84e179f6 100644 --- a/backend/contributions/tests/test_submission_limits.py +++ b/backend/contributions/tests/test_submission_limits.py @@ -241,7 +241,7 @@ def test_mission_list_exposes_capacity_fields_with_constant_queries(self): self._create_submission(state='pending', mission=other_mission, user=self.other_user) with CaptureQueriesContext(connection) as queries: - response = self.client.get('/api/v1/missions/?include_inactive=true&page_size=100') + response = self.client.get('/api/v1/missions/?include_inactive=true&page_size=50') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertLessEqual(len(queries), 4) diff --git a/backend/contributions/views.py b/backend/contributions/views.py index c3712de3..a51a23cf 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -3516,8 +3516,8 @@ def get_queryset(self): queryset = queryset.exclude(start_date__gt=timezone.now()) # Filter by contribution type if specified - contribution_type = self.request.query_params.get('contribution_type', None) - if contribution_type: + contribution_type = self._get_valid_contribution_type_param() + if contribution_type is not None: queryset = queryset.filter(contribution_type_id=contribution_type) # Filter by category if specified @@ -3527,6 +3527,17 @@ def get_queryset(self): return queryset + def _get_valid_contribution_type_param(self): + raw_value = self.request.query_params.get('contribution_type') + if raw_value in (None, ''): + return None + try: + return int(raw_value) + except (TypeError, ValueError): + raise serializers.ValidationError({ + 'contribution_type': 'Must be a valid contribution type id.' + }) + def _is_privileged_user(self): """Stewards and staff may see unstarted (unannounced) missions.""" user = self.request.user diff --git a/backend/deploy-apprunner-dev.sh b/backend/deploy-apprunner-dev.sh index 64e18d2e..73954a99 100755 --- a/backend/deploy-apprunner-dev.sh +++ b/backend/deploy-apprunner-dev.sh @@ -10,9 +10,11 @@ ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) ECR_REPO="$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$SERVICE_NAME" SSM_PREFIX="/tally-backend" SSM_ENV="dev" # Use dev environment +APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP=${APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP:-true} echo "Deploying to AWS App Runner: $SERVICE_NAME (DEV environment)" echo "Using SSM parameters from: $SSM_PREFIX/$SSM_ENV/" +echo "App Runner RUN_MIGRATIONS_ON_STARTUP: $APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP" echo "" # Build and push container image @@ -50,6 +52,7 @@ if aws apprunner describe-service --service-arn arn:aws:apprunner:$REGION:$ACCOU "RuntimeEnvironmentVariables": { "PYTHONPATH": "/app", "DJANGO_SETTINGS_MODULE": "tally.settings", + "RUN_MIGRATIONS_ON_STARTUP": "$APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP", "RECAPTCHA_ALLOW_TEST_KEYS": "true" }, "RuntimeEnvironmentSecrets": { @@ -241,6 +244,7 @@ EOF "RuntimeEnvironmentVariables": { "PYTHONPATH": "/app", "DJANGO_SETTINGS_MODULE": "tally.settings", + "RUN_MIGRATIONS_ON_STARTUP": "$APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP", "RECAPTCHA_ALLOW_TEST_KEYS": "true" }, "RuntimeEnvironmentSecrets": { diff --git a/backend/deploy-apprunner.sh b/backend/deploy-apprunner.sh index 6bcca503..ec7ffe1e 100755 --- a/backend/deploy-apprunner.sh +++ b/backend/deploy-apprunner.sh @@ -31,6 +31,7 @@ set -e SERVICE_NAME=${1:-tally-backend} VPC_CONNECTOR_ARN=${2:-} REGION=${AWS_DEFAULT_REGION:-us-east-1} +APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP=${APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP:-true} # Extract SSM parameter prefix from service name (remove -backend suffix) SSM_PREFIX="/${SERVICE_NAME%-backend}" @@ -43,6 +44,7 @@ else echo "Note: Database connectivity will use App Runner's external networking" fi echo "Using SSM parameter prefix: $SSM_PREFIX" +echo "App Runner RUN_MIGRATIONS_ON_STARTUP: $APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP" # Get AWS account ID ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) @@ -188,7 +190,8 @@ if aws apprunner describe-service --service-arn arn:aws:apprunner:$REGION:$ACCOU "Port": "8000", "RuntimeEnvironmentVariables": { "PYTHONPATH": "/app", - "DJANGO_SETTINGS_MODULE": "tally.settings" + "DJANGO_SETTINGS_MODULE": "tally.settings", + "RUN_MIGRATIONS_ON_STARTUP": "$APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP" }, "RuntimeEnvironmentSecrets": { "SECRET_KEY": "$SSM_PREFIX/prod/secret_key", @@ -303,7 +306,8 @@ else "Port": "8000", "RuntimeEnvironmentVariables": { "PYTHONPATH": "/app", - "DJANGO_SETTINGS_MODULE": "tally.settings" + "DJANGO_SETTINGS_MODULE": "tally.settings", + "RUN_MIGRATIONS_ON_STARTUP": "$APP_RUNNER_RUN_MIGRATIONS_ON_STARTUP" }, "RuntimeEnvironmentSecrets": { "SECRET_KEY": "$SSM_PREFIX/prod/secret_key", diff --git a/backend/github-actions-role-setup.sh b/backend/github-actions-role-setup.sh index 40b6b905..32e04a77 100755 --- a/backend/github-actions-role-setup.sh +++ b/backend/github-actions-role-setup.sh @@ -49,12 +49,19 @@ cat > github-actions-trust-policy.json << EOF } EOF -# Create IAM role -echo "Creating GitHub Actions IAM role..." -aws iam create-role \ - --role-name GitHubActions-TallyBackendDeploy \ - --assume-role-policy-document file://github-actions-trust-policy.json \ - --description "Role for GitHub Actions to deploy Tally backend to App Runner" +# Create or update IAM role +if aws iam get-role --role-name GitHubActions-TallyBackendDeploy >/dev/null 2>&1; then + echo "GitHub Actions IAM role already exists; updating trust policy..." + aws iam update-assume-role-policy \ + --role-name GitHubActions-TallyBackendDeploy \ + --policy-document file://github-actions-trust-policy.json +else + echo "Creating GitHub Actions IAM role..." + aws iam create-role \ + --role-name GitHubActions-TallyBackendDeploy \ + --assume-role-policy-document file://github-actions-trust-policy.json \ + --description "Role for GitHub Actions to deploy Tally backend to App Runner" +fi # Create policy for App Runner deployment. # diff --git a/backend/leaderboard/models.py b/backend/leaderboard/models.py index 8243a486..346b922b 100644 --- a/backend/leaderboard/models.py +++ b/backend/leaderboard/models.py @@ -1,4 +1,6 @@ -from django.db import models, transaction +import zlib + +from django.db import connection, models, transaction from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.conf import settings @@ -12,6 +14,25 @@ logger = get_app_logger('leaderboard') +LEADERBOARD_RANK_LOCK_NAMESPACE = 0x1EADBEEF + + +def _signed_int32(value): + return value - 2**32 if value >= 2**31 else value + + +def _lock_leaderboard_rank_update(leaderboard_type): + if connection.vendor != 'postgresql': + return + + lock_key = _signed_int32(zlib.crc32(leaderboard_type.encode('utf-8'))) + with connection.cursor() as cursor: + cursor.execute( + 'SELECT pg_advisory_xact_lock(%s, %s)', + [LEADERBOARD_RANK_LOCK_NAMESPACE, lock_key], + ) + + # Onboarding/journey contribution slugs excluded from referral point calculations. # These are signup markers and journey milestones, not actual contribution work. # Gate 1 (user eligibility) is handled by get_eligible_referred_user_ids(). @@ -388,7 +409,17 @@ def update_leaderboard_ranks(cls, leaderboard_type): """ if leaderboard_type not in LEADERBOARD_CONFIG: return - + + if connection.vendor == 'postgresql': + with transaction.atomic(): + _lock_leaderboard_rank_update(leaderboard_type) + cls._update_leaderboard_ranks_unlocked(leaderboard_type) + return + + cls._update_leaderboard_ranks_unlocked(leaderboard_type) + + @classmethod + def _update_leaderboard_ranks_unlocked(cls, leaderboard_type): config = LEADERBOARD_CONFIG[leaderboard_type] ranking_order = config['ranking_order'] @@ -543,7 +574,7 @@ def update_user_leaderboard_entries(user): # Step 5: Update ranks for all affected leaderboards affected_leaderboards = set(qualified_leaderboards) | removed_leaderboards - for leaderboard_type in affected_leaderboards: + for leaderboard_type in sorted(affected_leaderboards): LeaderboardEntry.update_leaderboard_ranks(leaderboard_type) @@ -578,7 +609,7 @@ def update_leaderboard_on_contribution_delete(sender, instance, **kwargs): entry.delete() affected_types.add(entry.type) - for leaderboard_type in affected_types: + for leaderboard_type in sorted(affected_types): LeaderboardEntry.update_leaderboard_ranks(leaderboard_type) # Mirror the save path: a referred user's contribution also feeds the @@ -793,7 +824,7 @@ def update_all_ranks(): Only visible users are ranked. Non-visible users get null rank. """ # Update each leaderboard type - for leaderboard_type in LEADERBOARD_CONFIG.keys(): + for leaderboard_type in sorted(LEADERBOARD_CONFIG.keys()): LeaderboardEntry.update_leaderboard_ranks(leaderboard_type) @@ -1071,7 +1102,7 @@ def recalculate_all_leaderboards(): LeaderboardEntry.objects.bulk_create(entries_to_create, batch_size=500) ReferralPoints.objects.bulk_create(referral_points_to_create, batch_size=500) - for leaderboard_type in ['validator', 'builder', 'validator-waitlist', 'validator-waitlist-graduation']: + for leaderboard_type in sorted(LEADERBOARD_CONFIG.keys()): LeaderboardEntry.update_leaderboard_ranks(leaderboard_type) return ( diff --git a/backend/leaderboard/tests/test_recalculation.py b/backend/leaderboard/tests/test_recalculation.py index 0fbfda5f..c30dfdea 100644 --- a/backend/leaderboard/tests/test_recalculation.py +++ b/backend/leaderboard/tests/test_recalculation.py @@ -123,6 +123,9 @@ def setUp(self): 'description': 'Double points for node running' } ) + self.multiplier1.multiplier_value = Decimal('2.0') + self.multiplier1.valid_from = timezone.now() - timezone.timedelta(days=30) + self.multiplier1.save(update_fields=['multiplier_value', 'valid_from']) self.multiplier2, _ = GlobalLeaderboardMultiplier.objects.get_or_create( contribution_type=self.builder_type, @@ -132,6 +135,9 @@ def setUp(self): 'description': '1.5x points for builders' } ) + self.multiplier2.multiplier_value = Decimal('1.5') + self.multiplier2.valid_from = timezone.now() - timezone.timedelta(days=30) + self.multiplier2.save(update_fields=['multiplier_value', 'valid_from']) # Add multipliers for waitlist and validator types too GlobalLeaderboardMultiplier.objects.create( @@ -140,7 +146,7 @@ def setUp(self): valid_from=timezone.now() - timezone.timedelta(days=30), description='Standard points for waitlist', ) - + GlobalLeaderboardMultiplier.objects.create( contribution_type=self.validator_type, multiplier_value=Decimal('1.0'), diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index f592d0d9..4a441658 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -177,7 +177,7 @@ def _create_current_mee6_xp(self, user, discord_id, xp): guild_id='1237055789441487021', guild_name='GenLayer', status=Mee6SyncRun.STATUS_SUCCESS, - page_size=1000, + page_size=50, pages_fetched=1, players_fetched=1, matched_players=1, diff --git a/backend/partners/tests/test_partners.py b/backend/partners/tests/test_partners.py index 39f0cff2..b4eb859a 100644 --- a/backend/partners/tests/test_partners.py +++ b/backend/partners/tests/test_partners.py @@ -209,7 +209,7 @@ def test_show_in_overview_filter(self): self.assertNotIn('inactive', slugs) # inactive everywhere self.assertNotIn('missing-overview-logo', slugs) # selected but unusable for marquee # Without the flag, an overview-hidden-but-active partner is still listed. - no_flag = self.client.get('/api/v1/partners/?page_size=100') + no_flag = self.client.get('/api/v1/partners/?page_size=50') no_flag_data = no_flag.json() no_flag_results = no_flag_data['results'] if isinstance(no_flag_data, dict) and 'results' in no_flag_data else no_flag_data no_flag_slugs = {p['slug'] for p in no_flag_results} diff --git a/backend/startup.sh b/backend/startup.sh index 75b3fe96..babd7fb5 100644 --- a/backend/startup.sh +++ b/backend/startup.sh @@ -6,8 +6,15 @@ echo "Starting Django application startup..." echo "Collecting static files..." python3 manage.py collectstatic --noinput -echo "Running database migrations..." -python3 manage.py migrate --noinput +case "${RUN_MIGRATIONS_ON_STARTUP:-true}" in + false|False|FALSE|0|no|No|NO) + echo "Skipping database migrations." + ;; + *) + echo "Running database migrations with advisory lock..." + python3 manage.py migrate_with_lock --noinput + ;; +esac echo "Startup complete. Starting Django server..." -exec "$@" \ No newline at end of file +exec "$@" diff --git a/backend/utils/management/__init__.py b/backend/utils/management/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/utils/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/utils/management/commands/__init__.py b/backend/utils/management/commands/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/utils/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/utils/management/commands/migrate_with_lock.py b/backend/utils/management/commands/migrate_with_lock.py new file mode 100644 index 00000000..0028ee61 --- /dev/null +++ b/backend/utils/management/commands/migrate_with_lock.py @@ -0,0 +1,173 @@ +import os +import time +import zlib + +from django.core.management import BaseCommand, CommandError, call_command +from django.db import connections + + +MIGRATION_LOCK_NAMESPACE = 0x54414C59 # "TALY", signed int32-safe. + + +def _env_float(name, default): + try: + return float(os.environ.get(name, default)) + except (TypeError, ValueError): + return default + + +def _signed_int32(value): + return value - 2**32 if value >= 2**31 else value + + +def _migration_lock_key(database_alias): + return _signed_int32(zlib.crc32(f'tally:migrations:{database_alias}'.encode('utf-8'))) + + +class Command(BaseCommand): + help = 'Run Django migrations under a PostgreSQL advisory lock.' + + def add_arguments(self, parser): + parser.add_argument('app_label', nargs='?') + parser.add_argument('migration_name', nargs='?') + parser.add_argument( + '--database', + default='default', + help='Database to migrate. Defaults to the "default" database.', + ) + parser.add_argument( + '--noinput', + '--no-input', + action='store_false', + dest='interactive', + default=True, + help='Do not prompt the user for input.', + ) + parser.add_argument( + '--fake', + action='store_true', + help='Mark migrations as run without actually running them.', + ) + parser.add_argument( + '--fake-initial', + action='store_true', + dest='fake_initial', + help='Detect initial migrations as already applied when tables exist.', + ) + parser.add_argument( + '--plan', + action='store_true', + help='Show the migration plan instead of running migrations.', + ) + parser.add_argument( + '--check', + action='store_true', + dest='check_unapplied', + help='Exit non-zero if unapplied migrations exist.', + ) + parser.add_argument( + '--prune', + action='store_true', + help='Delete nonexistent migrations from the django_migrations table.', + ) + parser.add_argument( + '--lock-timeout-seconds', + type=float, + default=_env_float('MIGRATION_LOCK_TIMEOUT_SECONDS', 900), + help='Seconds to wait for the migration lock before failing.', + ) + parser.add_argument( + '--lock-poll-seconds', + type=float, + default=_env_float('MIGRATION_LOCK_POLL_SECONDS', 2), + help='Seconds between migration lock acquisition attempts.', + ) + + def handle(self, *args, **options): + database = options['database'] + connection = connections[database] + migrate_args = [ + value for value in (options.get('app_label'), options.get('migration_name')) if value + ] + migrate_options = { + 'database': database, + 'interactive': options['interactive'], + 'fake': options['fake'], + 'fake_initial': options['fake_initial'], + 'plan': options['plan'], + 'check_unapplied': options['check_unapplied'], + 'prune': options['prune'], + 'verbosity': options['verbosity'], + 'stdout': self.stdout, + 'stderr': self.stderr, + } + + if connection.vendor != 'postgresql': + self.stdout.write( + f'Database backend is {connection.vendor}; running migrations without advisory lock.' + ) + call_command('migrate', *migrate_args, **migrate_options) + return + + timeout_seconds = options['lock_timeout_seconds'] + poll_seconds = options['lock_poll_seconds'] + if timeout_seconds < 0: + raise CommandError('--lock-timeout-seconds must be >= 0') + if poll_seconds <= 0: + raise CommandError('--lock-poll-seconds must be > 0') + + lock_key = _migration_lock_key(database) + acquired = False + connection.ensure_connection() + try: + self._acquire_lock(connection, lock_key, timeout_seconds, poll_seconds) + acquired = True + call_command('migrate', *migrate_args, **migrate_options) + finally: + if acquired: + self._release_lock(connection, lock_key) + + def _acquire_lock(self, connection, lock_key, timeout_seconds, poll_seconds): + deadline = time.monotonic() + timeout_seconds + next_notice = 0 + + while True: + with connection.cursor() as cursor: + cursor.execute( + 'SELECT pg_try_advisory_lock(%s, %s)', + [MIGRATION_LOCK_NAMESPACE, lock_key], + ) + acquired = cursor.fetchone()[0] + + if acquired: + self.stdout.write('Acquired PostgreSQL migration advisory lock.') + return + + now = time.monotonic() + if now >= deadline: + raise CommandError( + f'Timed out after {timeout_seconds:g}s waiting for migration advisory lock.' + ) + + if now >= next_notice: + self.stdout.write('Waiting for another instance to finish migrations...') + next_notice = now + 30 + + time.sleep(min(poll_seconds, max(deadline - now, 0))) + + def _release_lock(self, connection, lock_key): + try: + with connection.cursor() as cursor: + cursor.execute( + 'SELECT pg_advisory_unlock(%s, %s)', + [MIGRATION_LOCK_NAMESPACE, lock_key], + ) + released = cursor.fetchone()[0] + except Exception as exc: + self.stderr.write(f'WARNING: could not release migration advisory lock: {exc}') + return + + if released: + self.stdout.write('Released PostgreSQL migration advisory lock.') + else: + self.stderr.write('WARNING: migration advisory lock was already released.') diff --git a/backend/utils/pagination.py b/backend/utils/pagination.py index 7584678a..c17e40fd 100644 --- a/backend/utils/pagination.py +++ b/backend/utils/pagination.py @@ -16,7 +16,7 @@ class SafePageNumberPagination(PageNumberPagination): # Enable client to control page size using 'page_size' query parameter page_size_query_param = 'page_size' # Set maximum page size to prevent abuse - max_page_size = 100 + max_page_size = 50 def paginate_queryset(self, queryset, request, view=None): """ diff --git a/backend/utils/tests/test_migrate_with_lock.py b/backend/utils/tests/test_migrate_with_lock.py new file mode 100644 index 00000000..ae1d41fa --- /dev/null +++ b/backend/utils/tests/test_migrate_with_lock.py @@ -0,0 +1,116 @@ +from io import StringIO +from unittest.mock import patch + +from django.core.management import CommandError +from django.test import SimpleTestCase + +from utils.management.commands import migrate_with_lock +from utils.management.commands.migrate_with_lock import Command + + +def _command_options(**overrides): + options = { + 'app_label': None, + 'migration_name': None, + 'database': 'default', + 'interactive': False, + 'fake': False, + 'fake_initial': False, + 'plan': False, + 'check_unapplied': False, + 'prune': False, + 'lock_timeout_seconds': 5, + 'lock_poll_seconds': 0.01, + 'verbosity': 1, + } + options.update(overrides) + return options + + +class FakeCursor: + def __init__(self, connection): + self.connection = connection + self.result = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def execute(self, sql, params): + self.connection.executed.append((sql, params)) + self.result = self.connection.results.pop(0) + + def fetchone(self): + return [self.result] + + +class FakeConnection: + def __init__(self, vendor='postgresql', results=None): + self.vendor = vendor + self.results = list(results or []) + self.executed = [] + self.ensure_connection_calls = 0 + + def ensure_connection(self): + self.ensure_connection_calls += 1 + + def cursor(self): + return FakeCursor(self) + + +class MigrateWithLockCommandTests(SimpleTestCase): + def run_command(self, connection, **options): + stdout = StringIO() + stderr = StringIO() + command = Command(stdout=stdout, stderr=stderr) + with patch.object(migrate_with_lock, 'connections', {'default': connection}): + with patch.object(migrate_with_lock, 'call_command') as call_command: + command.handle(**_command_options(**options)) + return call_command, stdout.getvalue(), stderr.getvalue() + + def test_non_postgres_runs_migrate_without_lock(self): + connection = FakeConnection(vendor='sqlite') + + call_command, stdout, _stderr = self.run_command(connection) + + self.assertIn('without advisory lock', stdout) + self.assertEqual(connection.executed, []) + call_command.assert_called_once() + + def test_postgres_runs_migrate_inside_advisory_lock(self): + connection = FakeConnection(results=[True, True]) + + call_command, stdout, _stderr = self.run_command(connection) + + self.assertEqual(connection.ensure_connection_calls, 1) + self.assertIn('Acquired PostgreSQL migration advisory lock', stdout) + self.assertIn('Released PostgreSQL migration advisory lock', stdout) + self.assertEqual(connection.executed[0][0], 'SELECT pg_try_advisory_lock(%s, %s)') + self.assertEqual(connection.executed[1][0], 'SELECT pg_advisory_unlock(%s, %s)') + call_command.assert_called_once() + + def test_postgres_releases_lock_when_migrate_fails(self): + connection = FakeConnection(results=[True, True]) + command = Command(stdout=StringIO(), stderr=StringIO()) + + with patch.object(migrate_with_lock, 'connections', {'default': connection}): + with patch.object(migrate_with_lock, 'call_command', side_effect=RuntimeError('boom')): + with self.assertRaises(RuntimeError): + command.handle(**_command_options()) + + self.assertEqual(connection.executed[0][0], 'SELECT pg_try_advisory_lock(%s, %s)') + self.assertEqual(connection.executed[1][0], 'SELECT pg_advisory_unlock(%s, %s)') + + def test_postgres_times_out_when_lock_is_not_available(self): + connection = FakeConnection(results=[False]) + command = Command(stdout=StringIO(), stderr=StringIO()) + + with patch.object(migrate_with_lock, 'connections', {'default': connection}): + with patch.object(migrate_with_lock, 'call_command') as call_command: + with self.assertRaises(CommandError): + command.handle(**_command_options(lock_timeout_seconds=0)) + + call_command.assert_not_called() + self.assertEqual(connection.executed[0][0], 'SELECT pg_try_advisory_lock(%s, %s)') diff --git a/frontend/src/components/Missions.svelte b/frontend/src/components/Missions.svelte index c0caf2d1..26bca745 100644 --- a/frontend/src/components/Missions.svelte +++ b/frontend/src/components/Missions.svelte @@ -25,15 +25,10 @@ async function fetchMissions(category = activeCategory) { try { loading = true; - const response = await contributionsAPI.getMissions({ + const response = await contributionsAPI.getAllMissions({ category: category !== 'global' ? category : undefined }); - - if (response.data?.results !== undefined) { - missions = response.data.results || []; - } else { - missions = response.data || []; - } + missions = response.data || []; missions.sort((a, b) => { if (!a.end_date && !b.end_date) return 0; diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index b4145eba..1d8a510c 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -28,11 +28,10 @@ async function loadStewardNavigationPermissions() { try { await stewardPermissions.load(); - const response = await contributionsAPI.getContributionTypes({ - page_size: 100, + const response = await contributionsAPI.getAllContributionTypes({ category: 'community', }); - communityContributionTypes = response.data.results || response.data || []; + communityContributionTypes = response.data || []; } catch (err) { communityContributionTypes = []; } diff --git a/frontend/src/components/portal/PartnerLogoBanner.svelte b/frontend/src/components/portal/PartnerLogoBanner.svelte index 8afb0b25..0de6deeb 100644 --- a/frontend/src/components/portal/PartnerLogoBanner.svelte +++ b/frontend/src/components/portal/PartnerLogoBanner.svelte @@ -7,8 +7,8 @@ onMount(async () => { try { - const response = await partnersAPI.list({ ordering: 'display_order', page_size: 100, show_in_overview: true }); - const data = response.data?.results || response.data || []; + const response = await partnersAPI.listAll({ ordering: 'display_order', show_in_overview: true }); + const data = response.data || []; partners = data .map((partner) => ({ ...partner, diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index f1ad1695..90b2a4ca 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -2,6 +2,104 @@ import axios from 'axios'; import { API_BASE_URL } from './config.js'; import { attachCsrfToken } from './csrf.js'; +const INVALID_QUERY_VALUES = new Set(['undefined', 'null']); +const METRICS_CACHE_TTL_MS = 30 * 1000; +const SMALL_CATALOG_PAGE_SIZE = 50; +const SMALL_CATALOG_MAX_PAGES = 20; +const metricsRequestCache = new Map(); + +function isInvalidQueryValue(value) { + return value === undefined || value === null || INVALID_QUERY_VALUES.has(value); +} + +function sanitizeQueryValue(value) { + if (Array.isArray(value)) { + return value.filter(item => !isInvalidQueryValue(item)); + } + return value; +} + +function sanitizeRequestParams(params) { + if (!params) return params; + + if (typeof URLSearchParams !== 'undefined' && params instanceof URLSearchParams) { + const nextParams = new URLSearchParams(); + params.forEach((value, key) => { + if (!isInvalidQueryValue(value)) { + nextParams.append(key, value); + } + }); + return nextParams; + } + + return Object.fromEntries( + Object.entries(params) + .filter(([, value]) => !isInvalidQueryValue(value)) + .map(([key, value]) => [key, sanitizeQueryValue(value)]) + ); +} + +function getCachedMetricsRequest(key, requestFn) { + const cached = metricsRequestCache.get(key); + const now = Date.now(); + + if (cached?.response && now - cached.timestamp < METRICS_CACHE_TTL_MS) { + return Promise.resolve(cached.response); + } + + if (cached?.promise) { + return cached.promise; + } + + const promise = requestFn() + .then((response) => { + metricsRequestCache.set(key, { + response, + timestamp: Date.now(), + }); + return response; + }) + .catch((error) => { + metricsRequestCache.delete(key); + throw error; + }); + + metricsRequestCache.set(key, { promise, timestamp: now }); + return promise; +} + +async function fetchSmallCatalogPages(endpoint, params = {}) { + const results = []; + const baseParams = { + ...params, + page_size: SMALL_CATALOG_PAGE_SIZE, + }; + + delete baseParams.page; + + for (let page = 1; page <= SMALL_CATALOG_MAX_PAGES; page += 1) { + const response = await api.get(endpoint, { + params: { + ...baseParams, + page, + }, + }); + const data = response.data; + + if (Array.isArray(data)) { + return data; + } + + results.push(...(data?.results || [])); + + if (!data?.next) { + return results; + } + } + + throw new Error(`Small catalog ${endpoint} exceeded ${SMALL_CATALOG_MAX_PAGES} pages`); +} + // Create axios instance with base configuration const api = axios.create({ baseURL: `${API_BASE_URL}/api/v1`, @@ -17,6 +115,7 @@ api.interceptors.request.use( async (config) => { // Ensure withCredentials is always true config.withCredentials = true; + config.params = sanitizeRequestParams(config.params); // Don't override Content-Type for FormData if (config.data instanceof FormData) { @@ -72,13 +171,15 @@ export const contributionsAPI = { getContribution: (id) => api.get(`/contributions/${id}/`), getContributionsByUser: (address) => api.get(`/contributions/?user_address=${address}`), getContributionTypes: (params) => { - // Set a high page_size to get all contribution types in one request + // Contribution type catalogs are small; keep the request bounded. const enhancedParams = { - page_size: 100, + page_size: 50, ...params }; return api.get('/contribution-types/', { params: enhancedParams }); }, + getAllContributionTypes: (params = {}) => + fetchSmallCatalogPages('/contribution-types/', params).then(data => ({ data })), getContributionType: (id) => api.get(`/contribution-types/${id}/`), getContributionTypeMultipliers: (typeId) => api.get(`/contribution-type-multipliers/?contribution_type=${typeId}`), getContributionTypeStatistics: (params) => api.get('/contribution-types/statistics/', { params }), @@ -90,6 +191,8 @@ export const contributionsAPI = { data: { count: res.data.contribution_count } })), getMissions: (params) => api.get('/missions/', { params }), + getAllMissions: (params = {}) => + fetchSmallCatalogPages('/missions/', params).then(data => ({ data })), getMission: (id) => api.get(`/missions/${id}/`), /** @param {string | number} id */ getMissionStats: (id) => api.get(`/missions/${id}/stats/`), @@ -170,34 +273,16 @@ export const statsAPI = { } }; -let overviewMetricsResponse = null; -let overviewMetricsFetchedAt = 0; -let overviewMetricsRequest = null; -const OVERVIEW_METRICS_CACHE_MS = 30000; - // Metrics API export const metricsAPI = { - getOverview: () => { - const now = Date.now(); - if (overviewMetricsResponse && now - overviewMetricsFetchedAt < OVERVIEW_METRICS_CACHE_MS) { - return Promise.resolve(overviewMetricsResponse); - } - if (overviewMetricsRequest) return overviewMetricsRequest; - - overviewMetricsRequest = (async () => { - try { - const response = await api.get('/metrics/overview/'); - overviewMetricsResponse = response; - overviewMetricsFetchedAt = Date.now(); - return response; - } finally { - overviewMetricsRequest = null; - } - })(); - - return overviewMetricsRequest; - }, - getNetworkActivity: () => api.get('/metrics/overview/network-activity/'), + getOverview: () => getCachedMetricsRequest( + 'overview', + () => api.get('/metrics/overview/') + ), + getNetworkActivity: () => getCachedMetricsRequest( + 'network-activity', + () => api.get('/metrics/overview/network-activity/') + ), }; // Validators API @@ -414,6 +499,8 @@ export const alertsAPI = { // Ecosystem Partners API export const partnersAPI = { list: (params) => api.get('/partners/', { params }), + listAll: (params = {}) => + fetchSmallCatalogPages('/partners/', params).then(data => ({ data })), get: (slug) => api.get(`/partners/${slug}/`), }; diff --git a/frontend/src/lib/auth.js b/frontend/src/lib/auth.js index ee8049c9..2f35631e 100644 --- a/frontend/src/lib/auth.js +++ b/frontend/src/lib/auth.js @@ -416,6 +416,7 @@ export async function signInWithEthereum(provider = null, walletName = 'wallet', // Track verification promise to prevent duplicate calls let verificationInProgress = null; +let refreshSessionPromise = null; /** * Verify authentication status - only once per session @@ -509,14 +510,24 @@ export async function logout() { * @returns {Promise} Success status */ export async function refreshSession() { - try { - await authAxios.post(API_ENDPOINTS.REFRESH); - return true; - } catch (error) { - // If refresh fails, verify auth state again - await verifyAuth(); - return false; + if (refreshSessionPromise) { + return refreshSessionPromise; } + + refreshSessionPromise = (async () => { + try { + await authAxios.post(API_ENDPOINTS.REFRESH); + return true; + } catch (error) { + // If refresh fails, verify auth state again + await verifyAuth(); + return false; + } finally { + refreshSessionPromise = null; + } + })(); + + return refreshSessionPromise; } export async function startPendingSignupEmail(data) { @@ -719,7 +730,7 @@ if (typeof window !== 'undefined') { // Set up periodic session refresh to keep user logged in setInterval(async () => { const state = authState.get(); - if (state.isAuthenticated) { + if (state.isAuthenticated && document.hidden !== true) { try { await refreshSession(); } catch (error) { diff --git a/frontend/src/lib/missionsStore.js b/frontend/src/lib/missionsStore.js index 1fae0fa4..03c77d88 100644 --- a/frontend/src/lib/missionsStore.js +++ b/frontend/src/lib/missionsStore.js @@ -56,8 +56,8 @@ export async function getMissions(params = {}) { // Create new request and store the promise const requestPromise = (async () => { try { - const response = await contributionsAPI.getMissions(params); - const missions = response.data.results || response.data || []; + const response = await contributionsAPI.getAllMissions(params); + const missions = response.data || []; // Store in cache cache.set(cacheKey, { diff --git a/frontend/src/lib/userStore.js b/frontend/src/lib/userStore.js index 3a6e4bcb..114333b7 100644 --- a/frontend/src/lib/userStore.js +++ b/frontend/src/lib/userStore.js @@ -8,31 +8,43 @@ function createUserStore() { loading: false, error: null }); + let loadUserPromise = null; return { subscribe, // Load user data from API async loadUser() { - update(state => ({ ...state, loading: true, error: null })); - try { - const userData = await getCurrentUser(); - update(state => ({ - ...state, - user: userData, - loading: false, - error: null - })); - return userData; - } catch (err) { - update(state => ({ - ...state, - user: null, - loading: false, - error: err.message || 'Failed to load user data' - })); - throw err; + if (loadUserPromise) { + return loadUserPromise; } + + update(state => ({ ...state, loading: true, error: null })); + + loadUserPromise = (async () => { + try { + const userData = await getCurrentUser(); + update(state => ({ + ...state, + user: userData, + loading: false, + error: null + })); + return userData; + } catch (err) { + update(state => ({ + ...state, + user: null, + loading: false, + error: err.message || 'Failed to load user data' + })); + throw err; + } finally { + loadUserPromise = null; + } + })(); + + return loadUserPromise; }, // Update user data (partial update) diff --git a/frontend/src/lib/whatsNewStore.js b/frontend/src/lib/whatsNewStore.js index 9524236a..39806461 100644 --- a/frontend/src/lib/whatsNewStore.js +++ b/frontend/src/lib/whatsNewStore.js @@ -7,6 +7,7 @@ function createWhatsNewStore() { visible: false, unseenCount: 0, }); + let refreshPromise = null; async function loadUnseen() { const response = await whatsNewAPI.list(); @@ -21,10 +22,22 @@ function createWhatsNewStore() { } async function refresh() { - const response = await whatsNewAPI.unseenCount(); - const count = response.data?.count || 0; - update((state) => ({ ...state, unseenCount: count })); - return count; + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = (async () => { + try { + const response = await whatsNewAPI.unseenCount(); + const count = response.data?.count || 0; + update((state) => ({ ...state, unseenCount: count })); + return count; + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; } async function markSeen(ids, action = 'seen') { diff --git a/frontend/src/routes/AllContributions.svelte b/frontend/src/routes/AllContributions.svelte index 2c38830c..bd5053d3 100644 --- a/frontend/src/routes/AllContributions.svelte +++ b/frontend/src/routes/AllContributions.svelte @@ -482,8 +482,8 @@ typesLoading = true; try { const [types, missions] = await Promise.all([ - contributionsAPI.getContributionTypes().then((r) => r.data.results || r.data), - getMissions({ include_inactive: true, page_size: 100 }), + contributionsAPI.getAllContributionTypes().then((r) => r.data), + getMissions({ include_inactive: true }), ]); allTypes = Array.isArray(types) ? types.filter(isPublicContributionType).sort((a, b) => a.name.localeCompare(b.name)) diff --git a/frontend/src/routes/CommunityPoaps.svelte b/frontend/src/routes/CommunityPoaps.svelte index b402ac1d..654a3eb3 100644 --- a/frontend/src/routes/CommunityPoaps.svelte +++ b/frontend/src/routes/CommunityPoaps.svelte @@ -20,7 +20,7 @@ let loadingMore = $state(false); let latestPoapsRequestId = 0; - const PAGE_SIZE = 100; + const PAGE_SIZE = 48; const poapGradientStyle = getCategoryGradientStyle('community', '#7f52e1'); /** @param {number} [nextPage] @param {boolean} [append] */ diff --git a/frontend/src/routes/ContributionTypeDetail.svelte b/frontend/src/routes/ContributionTypeDetail.svelte index 64eeae85..565da857 100644 --- a/frontend/src/routes/ContributionTypeDetail.svelte +++ b/frontend/src/routes/ContributionTypeDetail.svelte @@ -105,7 +105,7 @@ const [typeRes, statsRes, missionsRes, highlightsRes, contributionsRes] = await Promise.all([ contributionsAPI.getContributionType(params.id), contributionsAPI.getContributionTypeStatistics(), - contributionsAPI.getMissions({ contribution_type: params.id }), + contributionsAPI.getAllMissions({ contribution_type: params.id }), contributionsAPI.getContributionTypeHighlights(params.id), contributionsAPI.getContributions({ contribution_type: params.id, @@ -118,7 +118,7 @@ contributionType = typeRes.data; const allStats = statsRes.data || []; statistics = allStats.find((stat) => String(stat.id) === String(params.id)) || {}; - missions = missionsRes.data?.results || missionsRes.data || []; + missions = missionsRes.data || []; highlights = visibleContributions(highlightsRes.data || []); contributions = visibleContributions(contributionsRes.data?.results || []); } catch (err) { diff --git a/frontend/src/routes/EcosystemPartners.svelte b/frontend/src/routes/EcosystemPartners.svelte index 2d7f94b9..57228be6 100644 --- a/frontend/src/routes/EcosystemPartners.svelte +++ b/frontend/src/routes/EcosystemPartners.svelte @@ -133,7 +133,7 @@ } const [partnersRes, validatorsRes, buildsRes] = await Promise.allSettled([ - partnersAPI.list({ page_size: 200 }), + partnersAPI.listAll(), validatorsAPI.getAllValidators(), projectsAPI.list(), ]); diff --git a/frontend/src/routes/Metrics.svelte b/frontend/src/routes/Metrics.svelte index bda3d32e..ab3d081a 100644 --- a/frontend/src/routes/Metrics.svelte +++ b/frontend/src/routes/Metrics.svelte @@ -1,7 +1,7 @@