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..edc88581 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -3516,8 +3516,14 @@ 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.request.query_params.get('contribution_type') + if contribution_type not in (None, ''): + try: + contribution_type = int(contribution_type) + except (TypeError, ValueError) as err: + raise serializers.ValidationError({ + 'contribution_type': 'Must be a valid contribution type id.' + }) from err queryset = queryset.filter(contribution_type_id=contribution_type) # Filter by category if specified diff --git a/backend/leaderboard/models.py b/backend/leaderboard/models.py index 8243a486..c143e34b 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,13 @@ def update_leaderboard_ranks(cls, leaderboard_type): """ if leaderboard_type not in LEADERBOARD_CONFIG: return - + + with transaction.atomic(): + _lock_leaderboard_rank_update(leaderboard_type) + 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 +570,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 +605,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 +820,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 +1098,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/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..3ff6866d 100644 --- a/backend/startup.sh +++ b/backend/startup.sh @@ -6,8 +6,13 @@ echo "Starting Django application startup..." echo "Collecting static files..." python3 manage.py collectstatic --noinput -echo "Running database migrations..." -python3 manage.py migrate --noinput +# production should later run migrations once during deploy, then set RUN_MIGRATIONS_ON_STARTUP=false. +if [ "${RUN_MIGRATIONS_ON_STARTUP:-true}" = "false" ]; then + echo "Skipping database migrations." +else + echo "Running database migrations with advisory lock..." + python3 manage.py migrate_with_lock --noinput +fi 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..dac1cc35 --- /dev/null +++ b/backend/utils/management/commands/migrate_with_lock.py @@ -0,0 +1,62 @@ +import zlib + +from django.core.management import BaseCommand, call_command +from django.db import connections + + +MIGRATION_LOCK_NAMESPACE = 0x54414C59 # "TALY", signed int32-safe. +# ponytail: hardcoded; make it a flag/env var if a deploy ever needs to tune it. +LOCK_TIMEOUT_SECONDS = 900 + + +def _signed_int32(value): + return value - 2**32 if value >= 2**31 else value + + +MIGRATION_LOCK_KEY = _signed_int32(zlib.crc32(b'tally:migrations:default')) + + +class Command(BaseCommand): + help = 'Run Django migrations under a PostgreSQL advisory lock.' + + def add_arguments(self, parser): + parser.add_argument( + '--noinput', + '--no-input', + action='store_false', + dest='interactive', + default=True, + help='Do not prompt the user for input.', + ) + + def handle(self, *args, **options): + connection = connections['default'] + migrate_options = { + 'interactive': options['interactive'], + '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_options) + return + + # Postgres does the waiting: lock_timeout bounds the blocking + # pg_advisory_lock call, and the session-level lock releases itself + # when this command's connection closes. + connection.ensure_connection() + with connection.cursor() as cursor: + cursor.execute(f"SET lock_timeout = '{int(LOCK_TIMEOUT_SECONDS)}s'") + self.stdout.write('Waiting for PostgreSQL migration advisory lock...') + cursor.execute( + 'SELECT pg_advisory_lock(%s, %s)', + [MIGRATION_LOCK_NAMESPACE, MIGRATION_LOCK_KEY], + ) + cursor.execute('SET lock_timeout = 0') + + self.stdout.write('Acquired PostgreSQL migration advisory lock.') + call_command('migrate', **migrate_options) 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..cd7c3801 --- /dev/null +++ b/backend/utils/tests/test_migrate_with_lock.py @@ -0,0 +1,74 @@ +from io import StringIO +from unittest.mock import patch + +from django.test import SimpleTestCase + +from utils.management.commands import migrate_with_lock +from utils.management.commands.migrate_with_lock import Command + + +class FakeCursor: + def __init__(self, connection): + self.connection = connection + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def execute(self, sql, params=None): + self.connection.executed.append((sql, params)) + + +class FakeConnection: + def __init__(self, vendor='postgresql'): + self.vendor = vendor + 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): + stdout = StringIO() + command = Command(stdout=stdout, stderr=StringIO()) + with ( + patch.object(migrate_with_lock, 'connections', {'default': connection}), + patch.object(migrate_with_lock, 'call_command') as call_command, + ): + command.handle(interactive=False, verbosity=1) + return call_command, stdout.getvalue() + + def test_non_postgres_runs_migrate_without_lock(self): + connection = FakeConnection(vendor='sqlite') + + call_command, stdout = self.run_command(connection) + + self.assertIn('without advisory lock', stdout) + self.assertEqual(connection.executed, []) + call_command.assert_called_once() + + def test_postgres_takes_advisory_lock_with_bounded_wait(self): + connection = FakeConnection() + + call_command, stdout = self.run_command(connection) + + self.assertEqual(connection.ensure_connection_calls, 1) + self.assertIn('Acquired PostgreSQL migration advisory lock', stdout) + executed_sql = [sql for sql, _params in connection.executed] + self.assertEqual(executed_sql, [ + "SET lock_timeout = '900s'", + 'SELECT pg_advisory_lock(%s, %s)', + 'SET lock_timeout = 0', + ]) + self.assertEqual( + connection.executed[1][1], + [migrate_with_lock.MIGRATION_LOCK_NAMESPACE, migrate_with_lock.MIGRATION_LOCK_KEY], + ) + call_command.assert_called_once() diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index feb6ed0e..84414735 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -453,7 +453,7 @@ const routes = { - `statsAPI` - Dashboard statistics - `journeyAPI` - Onboarding journeys (startBuilderJourney, startValidatorJourney, completeBuilderJourney, linkXAccount, linkDiscordAccount) - `creatorAPI` - Community/creator membership (joinAsCreator) - - `partnersAPI` - Ecosystem partners directory (`list`, `get(slug)`) + - `partnersAPI` - Ecosystem partners directory (`listAll`, `get(slug)`) - `genTvAPI` - Gen TV streams (`list`, `get(slug)`) - `poapsAPI` - POAP list/detail/claims, user POAPs, secret claims, mint-link claims, and recovery wallet verification - `validatorsAPI.getWallOfShame(params)` - Public Wall of Shame list for active validators with Grafana metrics/logs status badges (renders in `routes/WallOfShame.svelte`) @@ -535,7 +535,7 @@ Investor-oriented home page (`routes/Overview.svelte`), top to bottom: hero → - **`NetworkActivity.svelte`** - two-column section: LEFT `PortalStats`, RIGHT the network-activity panel (headline KPIs — decisions, chain TXs, DeFiLlama rank — + `DecisionsChart`). Fetches `metricsAPI.getNetworkActivity()`. (The old "Securing the network" validators panel was removed.) - **`PortalStats.svelte`** - "Portal contributors" panel: Builders / Validators / Community members / Contributions in a column, hexagon `CategoryIcon` style. Reads the **public** `metricsAPI.getOverview()` (`metrics.{builders,validators,community_members,contributions}.value`) — NOT `statsAPI.getDashboardStats()`, which is auth-only and would render blank for public visitors. - **`DecisionsChart.svelte`** - chart.js (already a dep) smooth multi-line area chart: decisions/day for Studio + asimov + bradbury over 7 days. IMPORTANT: chart.js mutates the arrays it receives, so de-proxy Svelte `$props` arrays before passing them to `new Chart` (else `state_descriptors_fixed`); the canvas is always mounted so `bind:this` is set before data arrives. -- **`PartnerLogoBanner.svelte`** - logos-only infinite marquee in TWO rows (even indices one row, odd the other, opposite directions, each tripled for a seamless loop). Fetches `partnersAPI.list({ show_in_overview: true, page_size: 100 })` so admins curate which partners appear (Partner.show_in_overview). Logos are flattened to grey silhouettes on a light band. +- **`PartnerLogoBanner.svelte`** - logos-only infinite marquee in TWO rows (even indices one row, odd the other, opposite directions, each tripled for a seamless loop). Fetches `partnersAPI.listAll({ show_in_overview: true })` so admins curate which partners appear (Partner.show_in_overview). Logos are flattened to grey silhouettes on a light band. - `metricsAPI` (`lib/api.js`): `getOverview()` (public; includes builders/validators/community_members/contributions counts + social + top_validators) and `getNetworkActivity()` (DB-snapshot-backed chart payload). #### Ecosystem Partners Components (`src/components/portal/partners/`) 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/Pagination.svelte b/frontend/src/components/Pagination.svelte index 3e1907bd..602d57d7 100644 --- a/frontend/src/components/Pagination.svelte +++ b/frontend/src/components/Pagination.svelte @@ -5,7 +5,9 @@ currentPage = 1, totalItems = 0, itemsPerPage = 10, - pageSizeOptions = [10, 25, 50, 100], + // ponytail: options stop at the server-side max_page_size (50); a larger option + // silently clamps while totalPages is computed from the requested size. + pageSizeOptions = [10, 25, 50], showPageSize = true, className = '' } = $props(); 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/components/portal/submit-contribution/SubmitContribution.svelte b/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte index 41fd1e0f..ab546042 100644 --- a/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte +++ b/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte @@ -110,8 +110,8 @@ onMount(async () => { try { loadingTypes = true; - const typesResponse = await contributionsAPI.getContributionTypes(); - const allTypes = typesResponse.data.results || typesResponse.data; + const typesResponse = await contributionsAPI.getAllContributionTypes(); + const allTypes = typesResponse.data || []; types = allTypes; diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index f1ad1695..d909ff60 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -2,6 +2,89 @@ 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); +} + +// All callers pass plain-object params; extend if URLSearchParams or array +// values ever show up. +function sanitizeRequestParams(params) { + if (!params) return params; + return Object.fromEntries( + Object.entries(params).filter(([, value]) => !isInvalidQueryValue(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; + } + } + + // Degrade gracefully if a "small" catalog outgrows the cap: partial data + // beats crashing every consumer. + console.warn(`Small catalog ${endpoint} exceeded ${SMALL_CATALOG_MAX_PAGES} pages; returning the first ${results.length} records`); + return results; +} + // Create axios instance with base configuration const api = axios.create({ baseURL: `${API_BASE_URL}/api/v1`, @@ -17,6 +100,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) { @@ -71,14 +155,8 @@ export const contributionsAPI = { getContributions: (params) => api.get('/contributions/', { params }), 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 - const enhancedParams = { - page_size: 100, - ...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 }), @@ -89,7 +167,8 @@ export const contributionsAPI = { getContributionCount: () => api.get('/leaderboard/stats/').then(res => ({ 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 +249,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 @@ -413,7 +474,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 41b67d7c..2b597516 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 @@ -517,14 +518,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) { @@ -727,7 +738,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) { @@ -735,6 +746,14 @@ if (typeof window !== 'undefined') { } } }, 5 * 60 * 1000); // Refresh every 5 minutes + + // The interval skips hidden tabs, so catch up as soon as the tab is visible + // again instead of waiting for the next tick. + document.addEventListener('visibilitychange', () => { + if (!document.hidden && authState.get().isAuthenticated) { + refreshSession().catch(() => {}); + } + }); } export { authState }; diff --git a/frontend/src/lib/components/ContributionSelection.svelte b/frontend/src/lib/components/ContributionSelection.svelte index 3e8f4339..fa58695a 100644 --- a/frontend/src/lib/components/ContributionSelection.svelte +++ b/frontend/src/lib/components/ContributionSelection.svelte @@ -85,8 +85,8 @@ if (onlySubmittable && !stewardMode) { params.is_submittable = 'true'; } - const response = await contributionsAPI.getContributionTypes(params); - contributionTypes = response.data.results || response.data; + const response = await contributionsAPI.getAllContributionTypes(params); + contributionTypes = response.data || []; // Fetch missions for all types in current category await loadMissions(); 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 c434b095..89fdde1f 100644 --- a/frontend/src/lib/userStore.js +++ b/frontend/src/lib/userStore.js @@ -8,36 +8,48 @@ 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) { - // Only a definitive auth rejection means "no user". On network/5xx - // failures keep any previously loaded user so role gating and journey - // state don't reset while the backend is down. - const status = err.response?.status; - const unauthenticated = status === 401 || status === 403; - update(state => ({ - ...state, - user: unauthenticated ? null : state.user, - 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) { + // Only a definitive auth rejection means "no user". On network/5xx + // failures keep any previously loaded user so role gating and journey + // state don't reset while the backend is down. + const status = err.response?.status; + const unauthenticated = status === 401 || status === 403; + update(state => ({ + ...state, + user: unauthenticated ? null : state.user, + 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/EditSubmission.svelte b/frontend/src/routes/EditSubmission.svelte index 71d97e11..380ff46e 100644 --- a/frontend/src/routes/EditSubmission.svelte +++ b/frontend/src/routes/EditSubmission.svelte @@ -395,8 +395,8 @@ // Load contribution types to collect evidence URL types try { - const typesResponse = await contributionsAPI.getContributionTypes(); - const allTypes = typesResponse.data.results || typesResponse.data; + const typesResponse = await contributionsAPI.getAllContributionTypes(); + const allTypes = typesResponse.data || []; const urlTypeMap = new Map(); for (const ct of allTypes) { for (const ut of ct.accepted_evidence_url_types || []) { 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 @@