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 $authState.isAuthenticated}
+
+ {/if}
-
{/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 @@