diff --git a/backend/.env.example b/backend/.env.example index d0fd0e7..a1fa147 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -78,4 +78,8 @@ GITHUB_ENCRYPTION_KEY=your_encryption_key_here # For production: Get real keys at https://www.google.com/recaptcha/admin # Select "reCAPTCHA v2" > "I'm not a robot" Checkbox RECAPTCHA_PUBLIC_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI -RECAPTCHA_PRIVATE_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe \ No newline at end of file +RECAPTCHA_PRIVATE_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe + +# Cron Sync Token +# Used for authenticating cron job requests to sync validators +CRON_SYNC_TOKEN=your_cron_sync_token_here \ No newline at end of file diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 5e7ec6d..17d48f6 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -206,7 +206,7 @@ class ContributionViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = ContributionSerializer permission_classes = [permissions.AllowAny] # Allow read-only access without authentication filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - filterset_fields = ['user', 'contribution_type'] + filterset_fields = ['user', 'contribution_type', 'mission'] search_fields = ['notes', 'user__email', 'user__name', 'contribution_type__name'] ordering_fields = ['contribution_date', 'created_at', 'points', 'frozen_global_points'] ordering = ['-contribution_date'] @@ -657,6 +657,25 @@ def create(self, request, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST ) + # Validate category restrictions based on user role + contribution_type_id = data.get('contribution_type') + if contribution_type_id: + try: + contribution_type = ContributionType.objects.select_related('category').get(id=contribution_type_id) + if contribution_type.category: + if contribution_type.category.slug == 'builder' and not hasattr(request.user, 'builder'): + return Response( + {'error': 'You must complete the Builder Welcome journey before submitting builder contributions.'}, + status=status.HTTP_403_FORBIDDEN + ) + if contribution_type.category.slug == 'validator' and not hasattr(request.user, 'validator'): + return Response( + {'error': 'You must complete the Validator Waitlist journey before submitting validator contributions.'}, + status=status.HTTP_403_FORBIDDEN + ) + except ContributionType.DoesNotExist: + pass + serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) diff --git a/backend/deploy-apprunner.sh b/backend/deploy-apprunner.sh index 555c601..b9e6295 100755 --- a/backend/deploy-apprunner.sh +++ b/backend/deploy-apprunner.sh @@ -211,7 +211,8 @@ if aws apprunner describe-service --service-arn arn:aws:apprunner:$REGION:$ACCOU "GITHUB_ENCRYPTION_KEY": "$SSM_PREFIX/prod/github_encryption_key", "GITHUB_REPO_TO_STAR": "$SSM_PREFIX/prod/github_repo_to_star", "RECAPTCHA_PUBLIC_KEY": "$SSM_PREFIX/prod/recaptcha_public_key", - "RECAPTCHA_PRIVATE_KEY": "$SSM_PREFIX/prod/recaptcha_private_key" + "RECAPTCHA_PRIVATE_KEY": "$SSM_PREFIX/prod/recaptcha_private_key", + "CRON_SYNC_TOKEN": "$SSM_PREFIX/prod/cron_sync_token" }, "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 tally.wsgi:application" }, @@ -223,8 +224,6 @@ if aws apprunner describe-service --service-arn arn:aws:apprunner:$REGION:$ACCOU } }, "InstanceConfiguration": { - "Cpu": "0.25 vCPU", - "Memory": "0.5 GB", "InstanceRoleArn": "arn:aws:iam::$ACCOUNT_ID:role/AppRunnerInstanceRole" }, "HealthCheckConfiguration": { @@ -293,7 +292,8 @@ else "GITHUB_ENCRYPTION_KEY": "$SSM_PREFIX/prod/github_encryption_key", "GITHUB_REPO_TO_STAR": "$SSM_PREFIX/prod/github_repo_to_star", "RECAPTCHA_PUBLIC_KEY": "$SSM_PREFIX/prod/recaptcha_public_key", - "RECAPTCHA_PRIVATE_KEY": "$SSM_PREFIX/prod/recaptcha_private_key" + "RECAPTCHA_PRIVATE_KEY": "$SSM_PREFIX/prod/recaptcha_private_key", + "CRON_SYNC_TOKEN": "$SSM_PREFIX/prod/cron_sync_token" }, "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 tally.wsgi:application" }, @@ -305,8 +305,6 @@ else } }, "InstanceConfiguration": { - "Cpu": "0.25 vCPU", - "Memory": "0.5 GB", "InstanceRoleArn": "arn:aws:iam::$ACCOUNT_ID:role/AppRunnerInstanceRole" }, "HealthCheckConfiguration": { diff --git a/backend/users/views.py b/backend/users/views.py index b816712..4f9bb07 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -5,7 +5,7 @@ from rest_framework.parsers import MultiPartParser, FormParser from django.shortcuts import get_object_or_404 from django.conf import settings -from django.db.models import Sum +from django.db.models import Sum, Q from .models import User from .serializers import UserSerializer, UserCreateSerializer, UserProfileUpdateSerializer from .cloudinary_service import CloudinaryService @@ -788,3 +788,31 @@ def referrals(self, request): 'validator_points': validator_pts, 'referrals': referral_list }) + + @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated]) + def search(self, request): + """Search users by name, address, email, or social handles.""" + query = request.query_params.get('q', '').strip() + + if len(query) < 2: + return Response([]) + + users = User.objects.filter( + Q(name__icontains=query) | + Q(address__icontains=query) | + Q(email__icontains=query) | + Q(twitter_handle__icontains=query) | + Q(discord_handle__icontains=query) | + Q(telegram_handle__icontains=query) | + Q(github_username__icontains=query) + ).filter(visible=True)[:10] + + return Response([ + { + 'id': user.id, + 'name': user.name, + 'address': user.address, + 'profile_image_url': user.profile_image_url + } + for user in users + ]) diff --git a/frontend/src/components/Missions.svelte b/frontend/src/components/Missions.svelte index 857798c..db8bd12 100644 --- a/frontend/src/components/Missions.svelte +++ b/frontend/src/components/Missions.svelte @@ -258,7 +258,12 @@
-

{mission.name}

+ {#if mission.contribution_type_details}
+ {/if} +
+ + diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index d120d96..c3b8867 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -62,7 +62,8 @@ export const usersAPI = { getDeploymentStatus: () => api.get('/users/deployment_status/'), getActiveValidators: () => api.get('/users/validators/'), getReferrals: () => api.get('/users/referrals/'), - getReferralPoints: () => api.get('/users/referral_points/') + getReferralPoints: () => api.get('/users/referral_points/'), + searchUsers: (query) => api.get('/users/search/', { params: { q: query } }) }; // API endpoints for contributions diff --git a/frontend/src/lib/components/ContributionSelection.svelte b/frontend/src/lib/components/ContributionSelection.svelte index 243618f..81febe1 100644 --- a/frontend/src/lib/components/ContributionSelection.svelte +++ b/frontend/src/lib/components/ContributionSelection.svelte @@ -15,9 +15,18 @@ providedContributionTypes = null, // Allow passing types from parent disabled = false, // Disable selection when locked (e.g., mission) selectedMission = $bindable(null), // Currently selected mission + isValidator = true, + isBuilder = true, onSelectionChange = () => {} } = $props(); + let validatorTabDisabled = $derived(!isValidator && !stewardMode); + let builderTabDisabled = $derived(!isBuilder && !stewardMode); + let currentCategoryDisabled = $derived( + (selectedCategory === 'validator' && !isValidator && !stewardMode) || + (selectedCategory === 'builder' && !isBuilder && !stewardMode) + ); + let contributionTypes = $state([]); let missions = $state([]); // All missions let filteredTypes = $state([]); @@ -268,6 +277,7 @@ type="button" class="category-btn" class:active={selectedCategory === 'validator'} + class:disabled={validatorTabDisabled} style={selectedCategory === 'validator' ? 'background: #e0f2fe; color: #0369a1;' : ''} onclick={() => selectCategory('validator')} > @@ -277,6 +287,7 @@ type="button" class="category-btn" class:active={selectedCategory === 'builder'} + class:disabled={builderTabDisabled} style={selectedCategory === 'builder' ? 'background: #ffedd5; color: #c2410c;' : ''} onclick={() => selectCategory('builder')} > @@ -296,17 +307,17 @@
{/if} + + {#if currentCategoryDisabled} +
+ {#if selectedCategory === 'validator'} + You need to be a validator to submit validator contributions. You can enter the Validator Waitlist. + {:else} + Complete the Builder Welcome journey to submit builder contributions. + {/if} +
+ {/if}
@@ -495,6 +516,31 @@ position: relative; } + .category-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .category-btn.disabled:hover { + background: transparent; + } + + .category-locked-message { + padding: 0.75rem 1rem; + background: #fef3c7; + border: 1px solid #f59e0b; + border-radius: 0.375rem; + margin-top: 0.75rem; + font-size: 0.875rem; + color: #92400e; + } + + .category-locked-message a { + color: #d97706; + font-weight: 500; + text-decoration: underline; + } + .contribution-type-selector { position: relative; margin-top: 0; diff --git a/frontend/src/routes/AllContributions.svelte b/frontend/src/routes/AllContributions.svelte index 5fca732..3237fd9 100644 --- a/frontend/src/routes/AllContributions.svelte +++ b/frontend/src/routes/AllContributions.svelte @@ -10,11 +10,13 @@ let participantFilter = $state(''); // Name or address let selectedCategory = $state('validator'); // 'validator' or 'builder' (no "all" option) let selectedContributionType = $state(null); // Full contribution type object + let selectedMission = $state(null); // Mission ID from ContributionSelection let sortBy = $state('-contribution_date'); // Sorting // === APPLIED FILTERS (what's actually being used) === let appliedCategory = $state('validator'); let appliedTypeId = $state(''); // Track which type filter is actually applied + let appliedMissionId = $state(''); // Track which mission filter is actually applied // === DATA STATE === let contributions = $state([]); @@ -42,8 +44,8 @@ page: currentPage, page_size: pageSize, ordering: sortBy, - // Only group if no specific type is applied - group_consecutive: !appliedTypeId + // Only group if no specific type or mission is applied + group_consecutive: !appliedTypeId && !appliedMissionId }; // Participant filter @@ -62,6 +64,9 @@ // Type filter - use the applied type, not the selected one if (appliedTypeId) params.contribution_type = appliedTypeId; + // Mission filter + if (appliedMissionId) params.mission = appliedMissionId; + // Fetch contributions const response = await contributionsAPI.getContributions(params); contributions = response.data.results || []; @@ -92,6 +97,7 @@ // Store the applied filters appliedCategory = selectedCategory; appliedTypeId = selectedContributionType?.id || ''; + appliedMissionId = selectedMission || ''; if (participantFilter && looksLikeAddress(participantFilter)) { loadUserDetails(participantFilter); @@ -106,8 +112,10 @@ participantFilter = ''; selectedCategory = 'validator'; // Reset to default selectedContributionType = null; + selectedMission = null; appliedCategory = 'validator'; appliedTypeId = ''; + appliedMissionId = ''; sortBy = '-contribution_date'; userDetails = null; currentPage = 1; @@ -143,6 +151,11 @@ appliedTypeId = typeId; // selectedContributionType will be set by ContributionSelection when it loads } + if (params.get('mission')) { + const missionId = params.get('mission'); + appliedMissionId = missionId; + selectedMission = Number(missionId); + } if (params.get('sort')) sortBy = params.get('sort'); if (params.get('page')) currentPage = Number(params.get('page')); } @@ -152,6 +165,7 @@ if (participantFilter) params.set('user', participantFilter); if (appliedCategory) params.set('category', appliedCategory); if (appliedTypeId) params.set('type', String(appliedTypeId)); + if (appliedMissionId) params.set('mission', String(appliedMissionId)); if (sortBy !== '-contribution_date') params.set('sort', sortBy); if (currentPage > 1) params.set('page', String(currentPage)); @@ -199,6 +213,7 @@ @@ -260,7 +275,7 @@ {error} showUser={true} category={appliedCategory} - disableGrouping={!!appliedTypeId} + disableGrouping={!!appliedTypeId || !!appliedMissionId} /> diff --git a/frontend/src/routes/SubmitContribution.svelte b/frontend/src/routes/SubmitContribution.svelte index 7372e52..7438b84 100644 --- a/frontend/src/routes/SubmitContribution.svelte +++ b/frontend/src/routes/SubmitContribution.svelte @@ -1,6 +1,7 @@