diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77164813..0030f15e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ All notable user-facing changes to this project will be documented in this file.
## Unreleased
+- The Submit Contribution page now shows a compact "Before you submit" guidance card that adapts to the selected type: a personal weekly slot counter for Projects, quality bars for Projects and Intelligent Contracts, a milestone eligibility check, one-click switching between related types, and review/appeal rules tucked behind a collapsed accordion (also shown on the Projects type page) (eddbe897)
+
- Finishing the Creator or Builder journey now actually grants the role: since late June the final "Become a Creator" / "Claim Builder Role" step failed for every new member with a generic error, and completion errors now show their real reason instead of a dead-end "try again" (9d546e70)
- Validators can link Telegram support groups to their validator: generate a one-time code on the new Telegram Support page, paste it in a Telegram group with the Deckard support bot, and the group is bound to the validator (multiple groups supported, codes expire in 48 hours and can be revoked) (0cd7e5f)
diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index bc5b2baa..75169183 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -73,6 +73,7 @@ backend/
- ContributionType - Categories with slug field, M2M `accepted_evidence_url_types`, optional global lifetime `max_submissions`, and optional `max_submissions_per_user_per_week`. The weekly limit uses Monday-Sunday UTC `SubmittedContribution.created_at` bounds and counts every state; edits/appeals reuse the same row and do not consume another slot. If an editable submission changes contribution type, the target type's capacity is checked in the submission's original creation week. `requires_ai_review` gates unengaged pending submissions from tier-1 stewards, while `escalation_threshold_points` converts tier-1 accepts whose contribution-date multiplied points meet the threshold into proposals. Migration 0084 backfills Builder-category types once; new Builder types default to `True` / `400` for values not supplied explicitly, and later explicit admin/model updates remain unchanged.
- AIReviewFeedback - Per-reviewer, per-AI-proposal benchmark feedback with an immutable `(proposal_source, proposal_source_id)` binding, timestamp metadata in `proposal_ref`, verdict, optional corrected decision/rubric ranges, typed anchored error claims, and a best-effort commit SHA pinned on first save. Records are unique by `(submitted_contribution, reviewer, proposal_source, proposal_source_id)` and never alter submission review state.
- SubmissionStateTransition - Append-only lifecycle log for submissions (migration 0079). One row per event (`submitted`/`review`/`bulk_reject`/`gate_reject`/`edited`/`canceled`/`appeal`/`evidence_added`/`escalated`/`admin`) with from_state/to_state/actor. Written by every path that changes `state` or clears `reviewed_by`/`reviewed_at` (creation via post_save signal; the rest inline at each call site, incl. the Tier-1 gate command and admin `save_model`). Never mutate or delete rows; read-only in admin. Rationale: row state is overwritten in place and re-open paths destroy review fields, so this log is the only durable decision/lifecycle history. Bulk reject also writes a per-submission decision SubmissionNote (`data.action='reject'`, `data.bulk=true`) so bulk decisions appear in the CRM timeline and note-based metrics like single rejects. The dead `resubmitted_more_info` filter (relied on `reviewed_at` surviving edits, impossible since 2026-06-22) was removed from both filtersets and the steward search grammar.
+ - SubmissionMoreInfoResponse - Immutable submitter response paired one-to-one with a structured request-more-info `SubmissionNote`, plus a nullable request-note FK for legacy `staff_reply` requests. It snapshots the request text/author/time, responder, and capped 1,000-character response so every request cycle remains auditable after the submission returns to pending. Submitter and steward serializers expose it as `more_info_requests[].response`; legacy requests expose `response: null` until actually answered and never receive fabricated history.
- Human review hierarchy - `Steward.tier` is reviewer (1), top-level (2), or apex (3); steward superusers act as tier 3. Tier 2+ gets the full permission matrix and unrestricted submission visibility. `SubmittedContribution.escalated_at` marks an active threshold proposal and is exposed only through the steward serializer. Tier-1 AI-gated visibility accepts durable AI proposal notes/rows, any active proposal, appeals, and transition-backed more-info resubmissions. Search supports `is_escalated`; the portal grammar maps `is:escalated` / `not:escalated`.
- Reviewer reward economy - Direct tier-1 decisions on threshold-enabled types earn 10 points for reviewing another user's submission; dedup uses the exact notes key `Review decision reward for submission {submission.id} [{action}]`, while bulk rejects, escalated accepts, and tier 2+ decisions are excluded. Every escalation records a human `ReviewProposal`, so a different finalizer rewards the escalator using rubric agreement for Builder Projects or binary action agreement for standard flows. All human-proposal rewards are then reduced proportionally when final points differ from proposed points.
- **Projects/Milestones split**: `contributions/project_milestones.py`
@@ -478,10 +479,24 @@ DELETE /api/v1/contributions/{id}/ (requires auth)
# Submissions (submitter-side)
GET /api/v1/submissions/my/ (requires auth, paginated user submissions)
+GET /api/v1/submissions/{id}/ (requires auth, owner-only source/detail lookup)
+PUT /api/v1/submissions/{id}/ (requires auth, owner-only editable submission update)
+PATCH /api/v1/submissions/{id}/ (requires auth, owner-only editable submission update)
GET /api/v1/submissions/accepted-projects/ (requires auth, the user's highlighted Projects contributions milestones can link to; optional ?submission=UUID includes that pending milestone's grandfathered current link; includes next_milestone_version and github_url)
POST /api/v1/submissions/{id}/appeal/ (requires auth, owner-only, one per submission)
POST /api/v1/submissions/{id}/add-evidence/ (requires auth, owner-only)
+# More-info resubmission contract
+# A PUT/PATCH while state=more_info_needed must include this write-only object:
+# "more_info_response": {"request_id": 123, "message": "What changed"}
+# request_id is null only for the current legacy staff_reply fallback. The
+# server locks the row, revalidates the resulting snapshot with current type,
+# mission, role/social/Discord, capacity and evidence rules, writes the paired
+# response, clears review fields, and transitions the same row to pending in
+# one transaction. Blank, stale, mismatched, duplicate and pending-state
+# responses are rejected. Read payloads expose the result under
+# more_info_requests[].response as {id, message, user, user_name, created_at}.
+
# Contribution Types
GET /api/v1/contribution-types/ (requires auth)
GET /api/v1/contribution-types/{id}/ (requires auth)
diff --git a/backend/contributions/migrations/0085_submissionmoreinforesponse.py b/backend/contributions/migrations/0085_submissionmoreinforesponse.py
new file mode 100644
index 00000000..32d993e1
--- /dev/null
+++ b/backend/contributions/migrations/0085_submissionmoreinforesponse.py
@@ -0,0 +1,80 @@
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contributions', '0084_enable_builder_review_hierarchy'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SubmissionMoreInfoResponse',
+ fields=[
+ (
+ 'id',
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name='ID',
+ ),
+ ),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('request_message', models.TextField()),
+ ('requested_at', models.DateTimeField(blank=True, null=True)),
+ ('message', models.TextField(max_length=1000)),
+ (
+ 'request_note',
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='submitter_response',
+ to='contributions.submissionnote',
+ ),
+ ),
+ (
+ 'requested_by',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='more_info_requests_answered',
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ 'responder',
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='submission_more_info_responses',
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ 'submitted_contribution',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='more_info_responses',
+ to='contributions.submittedcontribution',
+ ),
+ ),
+ ],
+ options={
+ 'ordering': ['-created_at', '-id'],
+ 'indexes': [
+ models.Index(
+ fields=['submitted_contribution', 'created_at'],
+ name='sub_info_resp_created_idx',
+ ),
+ ],
+ },
+ ),
+ ]
diff --git a/backend/contributions/models.py b/backend/contributions/models.py
index 956c6378..d5c75d74 100644
--- a/backend/contributions/models.py
+++ b/backend/contributions/models.py
@@ -1096,6 +1096,58 @@ def __str__(self):
return f"{prefix}{self.user} on {self.submitted_contribution} at {self.created_at}"
+class SubmissionMoreInfoResponse(BaseModel):
+ """A submitter's durable response to a steward more-information request.
+
+ New responses point at the structured ``SubmissionNote`` that recorded the
+ request. ``request_note`` remains nullable so legacy submissions whose
+ request only survives in ``staff_reply`` can still be answered; the request
+ snapshot fields preserve that context after ``staff_reply`` is cleared.
+ """
+
+ submitted_contribution = models.ForeignKey(
+ SubmittedContribution,
+ on_delete=models.CASCADE,
+ related_name='more_info_responses',
+ )
+ request_note = models.OneToOneField(
+ SubmissionNote,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='submitter_response',
+ )
+ request_message = models.TextField()
+ requested_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='more_info_requests_answered',
+ )
+ requested_at = models.DateTimeField(null=True, blank=True)
+ responder = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='submission_more_info_responses',
+ )
+ message = models.TextField(max_length=1000)
+
+ class Meta:
+ ordering = ['-created_at', '-id']
+ indexes = [
+ models.Index(
+ fields=['submitted_contribution', 'created_at'],
+ name='sub_info_resp_created_idx',
+ ),
+ ]
+
+ def __str__(self):
+ return f"Response on {self.submitted_contribution_id} at {self.created_at}"
+
+
class SubmissionStateTransition(BaseModel):
"""
Append-only log of submission lifecycle events.
diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py
index c200cf28..b719b18a 100644
--- a/backend/contributions/serializers.py
+++ b/backend/contributions/serializers.py
@@ -7,7 +7,7 @@
ContributionHighlight, Mission, StartupRequest, SubmissionNote,
FeaturedContent, Alert, EvidenceURLType, ContributionDiscordXPState,
DiscordXPDistributionEvent, ProjectMilestoneReview, ReviewProposal,
- AIReviewFeedback,
+ AIReviewFeedback, SubmissionMoreInfoResponse,
)
from .ai_attribution import AI_STEWARD_EMAIL
from .ai_feedback import normalize_feedback_payload
@@ -560,18 +560,70 @@ class Meta:
read_only_fields = ['url_type', 'created_at']
+class MoreInfoResponseInputSerializer(serializers.Serializer):
+ """Write-only response supplied when reopening a more-info submission."""
+
+ request_id = serializers.IntegerField(required=True, allow_null=True)
+ message = serializers.CharField(
+ required=True,
+ allow_blank=False,
+ max_length=1000,
+ trim_whitespace=True,
+ )
+
+
class MoreInfoRequestsMixin(serializers.Serializer):
more_info_requests = serializers.SerializerMethodField()
+ @staticmethod
+ def _user_name(user):
+ if not user:
+ return None
+ return user.name or (
+ f'{user.address[:10]}...' if user.address else str(user.id)
+ )
+
+ def _serialize_more_info_response(self, response):
+ if not response:
+ return None
+ return {
+ 'id': response.id,
+ 'message': response.message,
+ 'user': response.responder_id,
+ 'user_name': self._user_name(response.responder),
+ 'created_at': (
+ response.created_at.isoformat()
+ if response.created_at else None
+ ),
+ }
+
def get_more_info_requests(self, obj):
notes = getattr(obj, 'more_info_request_notes', None)
if notes is None:
- notes = (
+ notes = list(
obj.internal_notes
.filter(is_proposal=False, data__action='more_info')
.select_related('user')
.order_by('-created_at', '-id')
)
+ else:
+ notes = list(notes)
+
+ responses = getattr(obj, 'more_info_response_rows', None)
+ if responses is None:
+ responses = list(
+ obj.more_info_responses
+ .select_related('request_note', 'requested_by', 'responder')
+ .order_by('-created_at', '-id')
+ )
+ else:
+ responses = list(responses)
+
+ responses_by_request = {
+ response.request_note_id: response
+ for response in responses
+ if response.request_note_id
+ }
requests = []
for note in notes:
@@ -580,14 +632,68 @@ def get_more_info_requests(self, obj):
if not message:
continue
user = note.user
- user_name = user.name or (f'{user.address[:10]}...' if user.address else str(user.id))
requests.append({
'id': note.id,
'message': message,
'user': user.id,
- 'user_name': user_name,
+ 'user_name': self._user_name(user),
'created_at': note.created_at.isoformat() if note.created_at else None,
+ 'response': self._serialize_more_info_response(
+ responses_by_request.get(note.id)
+ ),
+ 'legacy': False,
+ '_sort_at': note.created_at.timestamp() if note.created_at else 0,
})
+
+ # Responses to pre-SubmissionNote requests retain an immutable request
+ # snapshot so the exchange remains visible after staff_reply is cleared.
+ for response in responses:
+ if response.request_note_id:
+ continue
+ requests.append({
+ 'id': None,
+ 'message': response.request_message,
+ 'user': response.requested_by_id,
+ 'user_name': self._user_name(response.requested_by),
+ 'created_at': (
+ response.requested_at.isoformat()
+ if response.requested_at else None
+ ),
+ 'response': self._serialize_more_info_response(response),
+ 'legacy': True,
+ '_sort_at': (
+ response.requested_at.timestamp()
+ if response.requested_at else 0
+ ),
+ })
+
+ has_unanswered_structured_request = any(
+ request['id'] is not None and request['response'] is None
+ for request in requests
+ )
+ if (
+ obj.state == 'more_info_needed'
+ and obj.staff_reply
+ and not has_unanswered_structured_request
+ ):
+ requests.append({
+ 'id': None,
+ 'message': obj.staff_reply,
+ 'user': obj.reviewed_by_id,
+ 'user_name': self._user_name(obj.reviewed_by),
+ 'created_at': (
+ obj.reviewed_at.isoformat() if obj.reviewed_at else None
+ ),
+ 'response': None,
+ 'legacy': True,
+ '_sort_at': (
+ obj.reviewed_at.timestamp() if obj.reviewed_at else 0
+ ),
+ })
+
+ requests.sort(key=lambda request: request['_sort_at'], reverse=True)
+ for request in requests:
+ request.pop('_sort_at', None)
return requests
@@ -612,6 +718,10 @@ class SubmittedContributionSerializer(MoreInfoRequestsMixin, serializers.ModelSe
required=False,
allow_null=True,
)
+ more_info_response = MoreInfoResponseInputSerializer(
+ required=False,
+ write_only=True,
+ )
recaptcha = ReCaptchaField(required=False) # Required only on create, handled in validate()
class Meta:
@@ -622,7 +732,8 @@ class Meta:
'proposed_points', 'converted_contribution', 'contribution', 'mission',
'project_contribution', 'milestone_version',
'has_appeal', 'appeal_reason', 'appealed_at', 'more_info_requests',
- 'created_at', 'updated_at', 'last_edited_at', 'recaptcha']
+ 'created_at', 'updated_at', 'last_edited_at', 'recaptcha',
+ 'more_info_response']
read_only_fields = ['id', 'user', 'state', 'staff_reply', 'reviewed_by',
'reviewed_at', 'created_at', 'updated_at', 'last_edited_at',
'proposed_points', 'converted_contribution',
@@ -703,6 +814,28 @@ def validate(self, data):
# Remove recaptcha from validated data as it's not a model field
data.pop('recaptcha', None)
+ more_info_response = data.get('more_info_response')
+ if self.instance:
+ if self.instance.state == 'more_info_needed' and not more_info_response:
+ raise serializers.ValidationError({
+ 'more_info_response': (
+ 'Tell the steward what changed before resubmitting.'
+ ),
+ })
+ if self.instance.state != 'more_info_needed' and more_info_response:
+ raise serializers.ValidationError({
+ 'more_info_response': (
+ 'A more-information response is only valid while '
+ 'information is requested.'
+ ),
+ })
+ elif more_info_response:
+ raise serializers.ValidationError({
+ 'more_info_response': (
+ 'New submissions cannot include a more-information response.'
+ ),
+ })
+
return data
def _validate_evidence_items(self, evidence_items_data,
@@ -929,6 +1062,9 @@ def update(self, instance, validated_data):
Evidence items with 'id' are updated, items without 'id' are created,
and items not in the list are deleted.
"""
+ # The view persists this audit record atomically with the state change.
+ validated_data.pop('more_info_response', None)
+
# Extract evidence items from initial_data (raw request data)
# since evidence_items is a SerializerMethodField and not in validated_data
evidence_items_data = self.initial_data.get('evidence_items', None)
diff --git a/backend/contributions/tests/test_appeal.py b/backend/contributions/tests/test_appeal.py
index 88cffcf5..6a035624 100644
--- a/backend/contributions/tests/test_appeal.py
+++ b/backend/contributions/tests/test_appeal.py
@@ -9,6 +9,7 @@
SubmissionNote,
ContributionType,
Category,
+ Evidence,
)
User = get_user_model()
@@ -267,11 +268,23 @@ def test_appealed_more_info_needed_submission_can_be_patched(self):
# Steward moves it to more_info_needed
submission.refresh_from_db()
submission.state = 'more_info_needed'
- submission.save(update_fields=['state'])
+ submission.staff_reply = 'Please provide more context.'
+ submission.save(update_fields=['state', 'staff_reply'])
+ Evidence.objects.create(
+ submitted_contribution=submission,
+ description='Original evidence',
+ url='https://example.com/appeal-more-info-evidence',
+ )
response = self.client.patch(
f'/api/v1/submissions/{submission.id}/',
- {'notes': 'Here is more info'},
+ {
+ 'notes': 'Here is more info',
+ 'more_info_response': {
+ 'request_id': None,
+ 'message': 'Added the requested context.',
+ },
+ },
format='json',
)
# Lock should NOT apply — should not be 403 from the appeal lock
diff --git a/backend/contributions/tests/test_discord_role_gating.py b/backend/contributions/tests/test_discord_role_gating.py
index b8d085d4..7b0f9c2c 100644
--- a/backend/contributions/tests/test_discord_role_gating.py
+++ b/backend/contributions/tests/test_discord_role_gating.py
@@ -1,6 +1,8 @@
+from types import SimpleNamespace
from unittest.mock import patch
from django.contrib.auth import get_user_model
+from django.db import transaction
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework import status
@@ -20,7 +22,7 @@
class DiscordRoleSubmissionGatingTest(TestCase):
def setUp(self):
self.category = Category.objects.create(
- name='Community',
+ name='Discord Role Test',
slug='community-test',
description='Community test category',
)
@@ -170,3 +172,40 @@ def test_refresh_failure_fails_closed(self, mock_sync_member_roles):
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
self.assertIn('temporarily unavailable', response.data['error'])
+
+ @patch('social_connections.discord_roles.DiscordRoleSyncService.sync_member_roles')
+ def test_edit_refreshes_roles_before_locking_submission(self, mock_sync_member_roles):
+ connection = DiscordConnection.objects.create(
+ user=self.user,
+ platform_user_id='discord-user',
+ platform_username='discorduser',
+ linked_at=timezone.now(),
+ )
+ submission = self._create_pending_submission(self.contribution_type)
+ baseline_savepoints = list(transaction.get_connection().savepoint_ids)
+
+ def sync_roles(discord_connection):
+ self.assertEqual(
+ list(transaction.get_connection().savepoint_ids),
+ baseline_savepoints,
+ )
+ discord_connection.guild_member = True
+ discord_connection.roles_synced_at = timezone.now()
+ discord_connection.save(
+ update_fields=['guild_member', 'roles_synced_at'],
+ )
+ discord_connection.current_roles.add(self.required_role)
+ return SimpleNamespace(connection=discord_connection)
+
+ mock_sync_member_roles.side_effect = sync_roles
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ {'notes': 'Updated after Discord refresh'},
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK, response.data)
+ mock_sync_member_roles.assert_called_once_with(connection)
+ submission.refresh_from_db()
+ self.assertEqual(submission.notes, 'Updated after Discord refresh')
diff --git a/backend/contributions/tests/test_is_submittable.py b/backend/contributions/tests/test_is_submittable.py
index 9542283b..221c7971 100644
--- a/backend/contributions/tests/test_is_submittable.py
+++ b/backend/contributions/tests/test_is_submittable.py
@@ -3,7 +3,7 @@
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework import status
-from contributions.models import ContributionType, Category, SubmittedContribution
+from contributions.models import ContributionType, Category, Evidence, SubmittedContribution
from users.models import User
@@ -276,9 +276,22 @@ def test_pending_submission_of_retired_type_can_be_edited(self):
def test_more_info_needed_submission_of_retired_type_can_be_edited(self):
submission = self._make_submission(state='more_info_needed')
+ submission.staff_reply = 'Please provide more context.'
+ submission.save(update_fields=['staff_reply'])
+ Evidence.objects.create(
+ submitted_contribution=submission,
+ description='Original evidence',
+ url='https://example.com/retired-type-evidence',
+ )
response = self.client.patch(
f'/api/v1/submissions/{submission.id}/',
- {'notes': 'Here is the requested info'},
+ {
+ 'notes': 'Here is the requested info',
+ 'more_info_response': {
+ 'request_id': None,
+ 'message': 'Added the requested context.',
+ },
+ },
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
diff --git a/backend/contributions/tests/test_more_info_responses.py b/backend/contributions/tests/test_more_info_responses.py
new file mode 100644
index 00000000..8c0d3ab6
--- /dev/null
+++ b/backend/contributions/tests/test_more_info_responses.py
@@ -0,0 +1,555 @@
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.db import IntegrityError
+from django.test import TestCase
+from django.utils import timezone
+from rest_framework import status
+from rest_framework.test import APIClient
+
+from contributions.models import (
+ Category,
+ ContributionType,
+ Evidence,
+ EvidenceURLType,
+ SubmissionMoreInfoResponse,
+ SubmissionNote,
+ SubmissionStateTransition,
+ SubmittedContribution,
+)
+from leaderboard.models import GlobalLeaderboardMultiplier
+from notifications.models import Notification
+from social_connections.models import DiscordRole
+from stewards.models import Steward, StewardPermission
+
+
+User = get_user_model()
+
+
+class MoreInfoResponseAPITest(TestCase):
+ def setUp(self):
+ self.category = Category.objects.create(
+ name='Response Test',
+ slug='response-test',
+ description='Response test category',
+ )
+ self.contribution_type = ContributionType.objects.create(
+ name='Response Test Type',
+ slug='response-test-type',
+ description='Response test contribution type',
+ category=self.category,
+ min_points=1,
+ max_points=100,
+ )
+ GlobalLeaderboardMultiplier.objects.create(
+ contribution_type=self.contribution_type,
+ multiplier_value=1,
+ valid_from=timezone.now() - timezone.timedelta(days=1),
+ )
+ self.other_evidence_type, _ = EvidenceURLType.objects.update_or_create(
+ slug='other',
+ defaults={
+ 'name': 'Other',
+ 'url_patterns': [],
+ 'is_generic': True,
+ 'order': 99,
+ },
+ )
+ self.owner = User.objects.create_user(
+ email='response-owner@test.com',
+ address='0x1111111111111111111111111111111111111111',
+ password='pass',
+ name='Response Owner',
+ )
+ self.other_user = User.objects.create_user(
+ email='response-other@test.com',
+ address='0x2222222222222222222222222222222222222222',
+ password='pass',
+ )
+ self.steward_user = User.objects.create_user(
+ email='response-steward@test.com',
+ address='0x3333333333333333333333333333333333333333',
+ password='pass',
+ name='Response Steward',
+ )
+ self.steward = Steward.objects.create(user=self.steward_user)
+ StewardPermission.objects.create(
+ steward=self.steward,
+ contribution_type=self.contribution_type,
+ action='accept',
+ )
+ self.client = APIClient()
+ self.client.force_authenticate(user=self.owner)
+
+ def _make_more_info_submission(
+ self,
+ *,
+ request_message='Please document the repository setup.',
+ structured=True,
+ owner=None,
+ ):
+ owner = owner or self.owner
+ submission = SubmittedContribution.objects.create(
+ user=owner,
+ contribution_type=self.contribution_type,
+ contribution_date=timezone.now(),
+ title='Repository work',
+ notes='Original submission notes',
+ state='more_info_needed',
+ staff_reply=request_message,
+ reviewed_by=self.steward_user,
+ reviewed_at=timezone.now(),
+ assigned_to=self.steward_user,
+ gate_reviewed=True,
+ )
+ Evidence.objects.create(
+ submitted_contribution=submission,
+ url=f'https://example.com/proof/{submission.id}',
+ description='Proof',
+ url_type=self.other_evidence_type,
+ )
+ note = None
+ if structured:
+ note = SubmissionNote.objects.create(
+ submitted_contribution=submission,
+ user=self.steward_user,
+ message=f'Reviewed: **more_info**\n\n> {request_message}',
+ is_proposal=False,
+ data={
+ 'action': 'more_info',
+ 'staff_reply': request_message,
+ },
+ )
+ return submission, note
+
+ def _response_payload(self, note, message='Updated the repository documentation.'):
+ return {
+ 'more_info_response': {
+ 'request_id': note.id if note else None,
+ 'message': message,
+ },
+ }
+
+ def test_response_is_required_and_blank_or_too_long_is_rejected(self):
+ submission, note = self._make_more_info_submission()
+ url = f'/api/v1/submissions/{submission.id}/'
+
+ missing = self.client.patch(url, {'notes': 'Edited'}, format='json')
+ self.assertEqual(missing.status_code, status.HTTP_400_BAD_REQUEST)
+
+ blank = self.client.patch(
+ url,
+ self._response_payload(note, message=' '),
+ format='json',
+ )
+ self.assertEqual(blank.status_code, status.HTTP_400_BAD_REQUEST)
+
+ too_long = self.client.patch(
+ url,
+ self._response_payload(note, message='x' * 1001),
+ format='json',
+ )
+ self.assertEqual(too_long.status_code, status.HTTP_400_BAD_REQUEST)
+
+ submission.refresh_from_db()
+ self.assertEqual(submission.state, 'more_info_needed')
+ self.assertEqual(submission.notes, 'Original submission notes')
+ self.assertFalse(
+ SubmissionMoreInfoResponse.objects.filter(
+ submitted_contribution=submission,
+ ).exists()
+ )
+
+ def test_direct_response_reopens_submission_and_serializes_pair(self):
+ submission, note = self._make_more_info_submission()
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(note),
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK, response.data)
+ submission.refresh_from_db()
+ self.assertEqual(submission.state, 'pending')
+ self.assertEqual(submission.staff_reply, '')
+ self.assertFalse(submission.gate_reviewed)
+ self.assertIsNone(submission.reviewed_by)
+ self.assertIsNone(submission.reviewed_at)
+
+ stored = SubmissionMoreInfoResponse.objects.get(
+ submitted_contribution=submission,
+ )
+ self.assertEqual(stored.request_note, note)
+ self.assertEqual(stored.request_message, note.data['staff_reply'])
+ self.assertEqual(stored.requested_by, self.steward_user)
+ self.assertEqual(stored.responder, self.owner)
+
+ (request_data,) = response.data['more_info_requests']
+ self.assertEqual(request_data['id'], note.id)
+ self.assertEqual(request_data['message'], note.data['staff_reply'])
+ self.assertEqual(
+ request_data['response']['message'],
+ 'Updated the repository documentation.',
+ )
+ self.assertEqual(request_data['response']['user'], self.owner.id)
+ self.assertIsNotNone(request_data['response']['created_at'])
+
+ transition = SubmissionStateTransition.objects.get(
+ submitted_contribution=submission,
+ event=SubmissionStateTransition.EVENT_EDITED,
+ )
+ self.assertEqual(
+ (transition.from_state, transition.to_state),
+ ('more_info_needed', 'pending'),
+ )
+ notification = Notification.objects.get(
+ recipient=self.steward_user,
+ event_type='submission.more_info_resubmitted',
+ )
+ self.assertIn('responded to your more-information request', notification.body)
+
+ def test_stale_mismatched_and_duplicate_responses_are_rejected(self):
+ submission, stale_note = self._make_more_info_submission()
+ current_note = SubmissionNote.objects.create(
+ submitted_contribution=submission,
+ user=self.steward_user,
+ message='Reviewed: **more_info**\n\n> Send the latest release link.',
+ data={
+ 'action': 'more_info',
+ 'staff_reply': 'Send the latest release link.',
+ },
+ )
+
+ stale = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(stale_note),
+ format='json',
+ )
+ self.assertEqual(stale.status_code, status.HTTP_409_CONFLICT)
+
+ other_submission, other_note = self._make_more_info_submission()
+ mismatched = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(other_note),
+ format='json',
+ )
+ self.assertEqual(mismatched.status_code, status.HTTP_409_CONFLICT)
+
+ successful = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(current_note),
+ format='json',
+ )
+ self.assertEqual(successful.status_code, status.HTTP_200_OK)
+
+ duplicate = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(current_note),
+ format='json',
+ )
+ self.assertEqual(duplicate.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(
+ SubmissionMoreInfoResponse.objects.filter(
+ submitted_contribution=submission,
+ ).count(),
+ 1,
+ )
+ self.assertEqual(other_submission.state, 'more_info_needed')
+
+ def test_source_and_request_are_owner_scoped(self):
+ submission, note = self._make_more_info_submission()
+ self.client.force_authenticate(user=self.other_user)
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(note),
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+ submission.refresh_from_db()
+ self.assertEqual(submission.state, 'more_info_needed')
+ self.assertFalse(
+ SubmissionMoreInfoResponse.objects.filter(
+ submitted_contribution=submission,
+ ).exists()
+ )
+
+ def test_failed_snapshot_validation_rolls_back_edits_and_response(self):
+ submission, note = self._make_more_info_submission()
+ github_type, _ = EvidenceURLType.objects.update_or_create(
+ slug='github-repo',
+ defaults={
+ 'name': 'GitHub Repository',
+ 'url_patterns': [r'^https?://github\.com/[^/]+/[^/]+/?$'],
+ 'is_generic': False,
+ 'order': 1,
+ 'ownership_social_account': '',
+ },
+ )
+ self.contribution_type.required_evidence_url_types.set([github_type])
+ payload = self._response_payload(note)
+ payload['notes'] = 'This edit must roll back.'
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ payload,
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ submission.refresh_from_db()
+ self.assertEqual(submission.state, 'more_info_needed')
+ self.assertEqual(submission.notes, 'Original submission notes')
+ self.assertFalse(
+ SubmissionMoreInfoResponse.objects.filter(
+ submitted_contribution=submission,
+ ).exists()
+ )
+
+ @patch(
+ 'contributions.views.SubmissionMoreInfoResponse.objects.create',
+ side_effect=IntegrityError('simulated response write failure'),
+ )
+ def test_response_write_failure_rolls_back_submission_edits(self, _create):
+ submission, note = self._make_more_info_submission()
+ self.client.raise_request_exception = False
+ payload = self._response_payload(note)
+ payload['notes'] = 'This must be rolled back with the response.'
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ payload,
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
+ submission.refresh_from_db()
+ self.assertEqual(submission.state, 'more_info_needed')
+ self.assertEqual(submission.notes, 'Original submission notes')
+ self.assertEqual(submission.staff_reply, note.data['staff_reply'])
+ self.assertFalse(submission.more_info_responses.exists())
+
+ def test_current_social_requirement_blocks_direct_resubmission(self):
+ submission, note = self._make_more_info_submission()
+ self.contribution_type.required_social_accounts = ['github']
+ self.contribution_type.save(update_fields=['required_social_accounts'])
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(note),
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ submission.refresh_from_db()
+ self.assertEqual(submission.state, 'more_info_needed')
+ self.assertFalse(submission.more_info_responses.exists())
+
+ def test_current_discord_role_requirement_blocks_direct_resubmission(self):
+ submission, note = self._make_more_info_submission()
+ role = DiscordRole.objects.create(
+ guild_id='response-guild',
+ role_id='response-role',
+ name='Response reviewer',
+ position=1,
+ )
+ self.contribution_type.required_discord_roles.add(role)
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(note),
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ submission.refresh_from_db()
+ self.assertEqual(submission.state, 'more_info_needed')
+ self.assertFalse(submission.more_info_responses.exists())
+
+ def test_current_url_ownership_rule_blocks_direct_resubmission(self):
+ submission, note = self._make_more_info_submission()
+ github_type, _ = EvidenceURLType.objects.update_or_create(
+ slug='github-repo',
+ defaults={
+ 'name': 'GitHub Repository',
+ 'url_patterns': [r'^https?://github\.com/[^/]+/[^/]+/?$'],
+ 'handle_extract_pattern': r'github\.com/(?P[^/]+)/',
+ 'ownership_social_account': 'github',
+ 'allow_duplicate': False,
+ 'is_generic': False,
+ 'order': 1,
+ },
+ )
+ self.contribution_type.accepted_evidence_url_types.set([github_type])
+ evidence = submission.evidence_items.get()
+ evidence.url = 'https://github.com/a-different-owner/project'
+ evidence.url_type = github_type
+ evidence.save()
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(note),
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ submission.refresh_from_db()
+ self.assertEqual(submission.state, 'more_info_needed')
+ self.assertFalse(submission.more_info_responses.exists())
+
+ def test_legacy_request_is_snapshotted_without_fabricating_history(self):
+ submission, note = self._make_more_info_submission(structured=False)
+ self.assertIsNone(note)
+
+ before = self.client.get(f'/api/v1/submissions/{submission.id}/')
+ self.assertEqual(before.status_code, status.HTTP_200_OK)
+ (active_request,) = before.data['more_info_requests']
+ self.assertTrue(active_request['legacy'])
+ self.assertIsNone(active_request['response'])
+ self.assertEqual(
+ SubmissionMoreInfoResponse.objects.filter(
+ submitted_contribution=submission,
+ ).count(),
+ 0,
+ )
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(None),
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK, response.data)
+ stored = SubmissionMoreInfoResponse.objects.get(
+ submitted_contribution=submission,
+ )
+ self.assertIsNone(stored.request_note)
+ self.assertEqual(
+ stored.request_message,
+ 'Please document the repository setup.',
+ )
+ (paired_request,) = response.data['more_info_requests']
+ self.assertTrue(paired_request['legacy'])
+ self.assertIsNotNone(paired_request['response'])
+
+ def test_each_more_information_cycle_keeps_its_own_response(self):
+ submission, first_note = self._make_more_info_submission(
+ request_message='Add setup instructions.',
+ )
+ first = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(first_note, 'Added setup instructions.'),
+ format='json',
+ )
+ self.assertEqual(first.status_code, status.HTTP_200_OK, first.data)
+
+ submission.refresh_from_db()
+ submission.state = 'more_info_needed'
+ submission.staff_reply = 'Now add a release link.'
+ submission.reviewed_by = self.steward_user
+ submission.reviewed_at = timezone.now()
+ submission.save(update_fields=[
+ 'state', 'staff_reply', 'reviewed_by', 'reviewed_at', 'updated_at',
+ ])
+ second_note = SubmissionNote.objects.create(
+ submitted_contribution=submission,
+ user=self.steward_user,
+ message='Reviewed: **more_info**\n\n> Now add a release link.',
+ data={
+ 'action': 'more_info',
+ 'staff_reply': 'Now add a release link.',
+ },
+ )
+
+ second = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(second_note, 'Added the release link.'),
+ format='json',
+ )
+
+ self.assertEqual(second.status_code, status.HTTP_200_OK, second.data)
+ self.assertEqual(
+ SubmissionMoreInfoResponse.objects.filter(
+ submitted_contribution=submission,
+ ).count(),
+ 2,
+ )
+ pairs = {
+ request['message']: request['response']['message']
+ for request in second.data['more_info_requests']
+ }
+ self.assertEqual(pairs, {
+ 'Add setup instructions.': 'Added setup instructions.',
+ 'Now add a release link.': 'Added the release link.',
+ })
+
+ def test_legacy_fallback_can_follow_an_answered_structured_cycle(self):
+ submission, first_note = self._make_more_info_submission(
+ request_message='Add setup instructions.',
+ )
+ first = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(first_note, 'Added setup instructions.'),
+ format='json',
+ )
+ self.assertEqual(first.status_code, status.HTTP_200_OK, first.data)
+
+ submission.refresh_from_db()
+ submission.state = 'more_info_needed'
+ submission.staff_reply = 'Add deployment notes from the legacy review.'
+ submission.reviewed_by = self.steward_user
+ submission.reviewed_at = timezone.now()
+ submission.save(update_fields=[
+ 'state', 'staff_reply', 'reviewed_by', 'reviewed_at', 'updated_at',
+ ])
+
+ second = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(None, 'Added the deployment notes.'),
+ format='json',
+ )
+
+ self.assertEqual(second.status_code, status.HTTP_200_OK, second.data)
+ self.assertEqual(
+ SubmissionMoreInfoResponse.objects.filter(
+ submitted_contribution=submission,
+ ).count(),
+ 2,
+ )
+ legacy_pair = next(
+ request
+ for request in second.data['more_info_requests']
+ if request['legacy']
+ )
+ self.assertEqual(
+ legacy_pair['message'],
+ 'Add deployment notes from the legacy review.',
+ )
+ self.assertEqual(
+ legacy_pair['response']['message'],
+ 'Added the deployment notes.',
+ )
+
+ def test_steward_search_matches_response_text(self):
+ submission, note = self._make_more_info_submission()
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ self._response_payload(
+ note,
+ 'Published the uncommon-response-token release notes.',
+ ),
+ format='json',
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK, response.data)
+
+ self.client.force_authenticate(user=self.steward_user)
+ search = self.client.get('/api/v1/steward-submissions/', {
+ 'state': 'pending',
+ 'search': 'uncommon-response-token',
+ })
+
+ self.assertEqual(search.status_code, status.HTTP_200_OK, search.data)
+ result_ids = {str(item['id']) for item in search.data['results']}
+ self.assertEqual(result_ids, {str(submission.id)})
diff --git a/backend/contributions/tests/test_state_transitions.py b/backend/contributions/tests/test_state_transitions.py
index 46ddd07c..b0a86064 100644
--- a/backend/contributions/tests/test_state_transitions.py
+++ b/backend/contributions/tests/test_state_transitions.py
@@ -9,6 +9,7 @@
from contributions.models import (
Category,
ContributionType,
+ Evidence,
SubmissionNote,
SubmissionStateTransition,
SubmittedContribution,
@@ -125,14 +126,26 @@ def test_bulk_reject_logs_transitions_and_decision_notes(self):
def test_edit_after_more_info_logs_edited_transition(self):
submission = self._make_submission(
state='more_info_needed',
+ staff_reply='Please provide more context.',
reviewed_by=self.steward_user,
reviewed_at=timezone.now(),
escalated_at=timezone.now(),
)
+ Evidence.objects.create(
+ submitted_contribution=submission,
+ description='Original evidence',
+ url='https://example.com/state-transition-evidence',
+ )
self.client.force_authenticate(user=self.owner)
response = self.client.patch(
f'/api/v1/submissions/{submission.id}/',
- {'notes': 'Here is the extra information'},
+ {
+ 'notes': 'Here is the extra information',
+ 'more_info_response': {
+ 'request_id': None,
+ 'message': 'Added the requested context.',
+ },
+ },
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
diff --git a/backend/contributions/tests/test_validator_category_gating.py b/backend/contributions/tests/test_validator_category_gating.py
index 2f607efb..43fe0a4e 100644
--- a/backend/contributions/tests/test_validator_category_gating.py
+++ b/backend/contributions/tests/test_validator_category_gating.py
@@ -4,7 +4,13 @@
from rest_framework.test import APIClient
from rest_framework import status
-from contributions.models import Contribution, ContributionType, Category, SubmittedContribution
+from contributions.models import (
+ Category,
+ Contribution,
+ ContributionType,
+ Evidence,
+ SubmittedContribution,
+)
from creators.models import Creator
from leaderboard.models import GlobalLeaderboardMultiplier
from validators.models import Validator
@@ -255,13 +261,13 @@ def test_plain_user_cannot_edit_submission_into_community_type(self):
submission.refresh_from_db()
self.assertEqual(submission.contribution_type, self.unrestricted_type)
- def test_plain_user_can_edit_unchanged_legacy_community_submission(self):
+ def test_plain_user_can_edit_unchanged_legacy_community_pending_submission(self):
submission = SubmittedContribution.objects.create(
user=self.plain_user,
contribution_type=self.community_type,
contribution_date=timezone.now(),
notes='Original legacy community submission',
- state='more_info_needed',
+ state='pending',
)
self.client.force_authenticate(user=self.plain_user)
@@ -276,6 +282,37 @@ def test_plain_user_can_edit_unchanged_legacy_community_submission(self):
self.assertEqual(submission.notes, 'Updated legacy community submission')
self.assertEqual(submission.contribution_type, self.community_type)
+ def test_plain_user_cannot_resubmit_legacy_community_submission(self):
+ submission = SubmittedContribution.objects.create(
+ user=self.plain_user,
+ contribution_type=self.community_type,
+ contribution_date=timezone.now(),
+ notes='Original legacy community submission',
+ state='more_info_needed',
+ staff_reply='Please provide more context.',
+ )
+ Evidence.objects.create(
+ submitted_contribution=submission,
+ description='Original evidence',
+ url='https://example.com/legacy-community-evidence',
+ )
+ self.client.force_authenticate(user=self.plain_user)
+
+ response = self.client.patch(
+ f'/api/v1/submissions/{submission.id}/',
+ {
+ 'more_info_response': {
+ 'request_id': None,
+ 'message': 'Added the requested context.',
+ },
+ },
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ submission.refresh_from_db()
+ self.assertEqual(submission.state, 'more_info_needed')
+
def test_plain_user_cannot_edit_submission_into_mission_only_type(self):
submission = self._pending_submission()
self.client.force_authenticate(user=self.plain_user)
diff --git a/backend/contributions/views.py b/backend/contributions/views.py
index 112b2553..5c768805 100644
--- a/backend/contributions/views.py
+++ b/backend/contributions/views.py
@@ -27,7 +27,8 @@
from django.shortcuts import get_object_or_404
from .models import (
ContributionType, Contribution, Evidence, SubmittedContribution,
- SubmissionNote, SubmissionStateTransition, ContributionHighlight,
+ SubmissionNote, SubmissionMoreInfoResponse, SubmissionStateTransition,
+ ContributionHighlight,
Mission, StartupRequest,
FeaturedContent, Alert, ContributionDiscordXPState,
DiscordXPDistributionEvent, ProjectMilestoneReview, ReviewProposal,
@@ -597,6 +598,15 @@ def get_queryset(self):
).select_related('user').order_by('-created_at', '-id'),
to_attr='more_info_request_notes',
),
+ Prefetch(
+ 'more_info_responses',
+ queryset=SubmissionMoreInfoResponse.objects.select_related(
+ 'request_note',
+ 'requested_by',
+ 'responder',
+ ).order_by('-created_at', '-id'),
+ to_attr='more_info_response_rows',
+ ),
).order_by('-created_at')
def get_serializer_context(self):
@@ -610,7 +620,13 @@ def get_serializer_context(self):
context['use_light_serializers'] = self.action == 'list'
return context
- def _validate_required_discord_roles(self, user, contribution_type):
+ def _validate_required_discord_roles(
+ self,
+ user,
+ contribution_type,
+ *,
+ sync_if_stale=True,
+ ):
"""Ensure the user has at least one role required by this type."""
required_roles = list(
contribution_type.required_discord_roles.filter(
@@ -636,7 +652,7 @@ def _validate_required_discord_roles(self, user, contribution_type):
<= int(getattr(settings, 'DISCORD_ROLE_SUBMISSION_SYNC_GRACE_SECONDS', 30))
)
- if not recently_synced:
+ if not recently_synced and sync_if_stale:
try:
from social_connections.discord_roles import (
DiscordRoleSyncConfigurationError,
@@ -694,6 +710,7 @@ def _validate_submission_contribution_type(
weekly_capacity_at=None,
allow_existing_community_submission=False,
skip_submittable_check=False,
+ sync_discord_roles=True,
):
"""Validate role, category, and requirement gates for submissions."""
if mission and contribution_type.id != mission.contribution_type_id:
@@ -761,12 +778,57 @@ def _validate_submission_contribution_type(
status=status.HTTP_403_FORBIDDEN
)
- discord_role_error = self._validate_required_discord_roles(user, contribution_type)
+ discord_role_error = self._validate_required_discord_roles(
+ user,
+ contribution_type,
+ sync_if_stale=sync_discord_roles,
+ )
if discord_role_error is not None:
return discord_role_error
return None
+ def _preflight_update_discord_roles(self, request, submission_id):
+ """Refresh Discord roles before the submission row is locked."""
+ submission = (
+ SubmittedContribution.objects
+ .filter(id=submission_id, user=request.user)
+ .only('contribution_type_id', 'state', 'has_appeal')
+ .first()
+ )
+ if not submission or submission.state not in ['pending', 'more_info_needed']:
+ return None, None
+ if submission.has_appeal and submission.state == 'pending':
+ return None, None
+
+ requested_type_id = request.data.get(
+ 'contribution_type',
+ submission.contribution_type_id,
+ )
+ try:
+ requested_type_id = int(requested_type_id)
+ except (TypeError, ValueError):
+ # The serializer will return the canonical field error.
+ return None, None
+
+ contribution_type = (
+ ContributionType.objects
+ .select_related('category')
+ .prefetch_related('required_discord_roles')
+ .filter(id=requested_type_id)
+ .first()
+ )
+ if not contribution_type:
+ return None, None
+
+ return (
+ self._validate_required_discord_roles(
+ request.user,
+ contribution_type,
+ ),
+ contribution_type.id,
+ )
+
def _project_link_error(
self,
user,
@@ -965,92 +1027,240 @@ def create(self, request, *args, **kwargs):
def update(self, request, *args, **kwargs):
"""Update submission (only allowed if state is 'pending' or 'more_info_needed')."""
partial = kwargs.pop('partial', False)
- instance = self.get_object()
+ (
+ discord_role_error,
+ preflight_contribution_type_id,
+ ) = self._preflight_update_discord_roles(request, kwargs.get('pk'))
+ if discord_role_error is not None:
+ return discord_role_error
- # Check if update is allowed
- if instance.state not in ['pending', 'more_info_needed']:
- return Response(
- {'error': 'Submission can only be edited when pending or when more information is requested.'},
- status=status.HTTP_403_FORBIDDEN
+ with transaction.atomic():
+ # Serialize all edits/resubmissions for a submission. In particular,
+ # two clicks on Resubmit must never create two responses for one
+ # steward request.
+ instance = get_object_or_404(
+ SubmittedContribution.objects
+ .select_for_update(),
+ id=kwargs.get('pk'),
+ user=request.user,
)
+ self.check_object_permissions(request, instance)
- # Appealed submissions are locked while awaiting re-review so the
- # submitter can't edit-around the appeal. Once a steward explicitly
- # asks for more info, edits are allowed again so the submitter can
- # respond.
- if instance.has_appeal and instance.state == 'pending':
- return Response(
- {'error': 'Appealed submissions cannot be edited while awaiting re-review.'},
- status=status.HTTP_403_FORBIDDEN
- )
+ # Check if update is allowed
+ if instance.state not in ['pending', 'more_info_needed']:
+ return Response(
+ {'error': 'Submission can only be edited when pending or when more information is requested.'},
+ status=status.HTTP_403_FORBIDDEN
+ )
- if 'mission' in request.data:
- requested_mission = request.data.get('mission')
- current_mission = str(instance.mission_id) if instance.mission_id else None
- if requested_mission in ('', None):
- requested_mission = None
- else:
- requested_mission = str(requested_mission)
- if requested_mission != current_mission:
+ # Appealed submissions are locked while awaiting re-review so the
+ # submitter can't edit-around the appeal. Once a steward explicitly
+ # asks for more info, edits are allowed again so the submitter can
+ # respond.
+ if instance.has_appeal and instance.state == 'pending':
return Response(
- {'error': 'Mission cannot be changed after submission.'},
- status=status.HTTP_400_BAD_REQUEST,
+ {'error': 'Appealed submissions cannot be edited while awaiting re-review.'},
+ status=status.HTTP_403_FORBIDDEN
)
- # Update the submission
- serializer = self.get_serializer(instance, data=request.data, partial=partial)
- serializer.is_valid(raise_exception=True)
- was_more_info_needed = instance.state == 'more_info_needed'
+ if 'mission' in request.data:
+ requested_mission = request.data.get('mission')
+ current_mission = str(instance.mission_id) if instance.mission_id else None
+ if requested_mission in ('', None):
+ requested_mission = None
+ else:
+ requested_mission = str(requested_mission)
+ if requested_mission != current_mission:
+ return Response(
+ {'error': 'Mission cannot be changed after submission.'},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
- contribution_type = (
- serializer.validated_data.get('contribution_type')
- or instance.contribution_type
- )
- mission = serializer.validated_data.get('mission', instance.mission)
- contribution_type = (
- ContributionType.objects
- .select_related('category')
- .prefetch_related('required_discord_roles')
- .get(id=contribution_type.id)
- )
- keeps_same_contribution_type = contribution_type.id == instance.contribution_type_id
- contribution_type_error = self._validate_submission_contribution_type(
- request.user,
- contribution_type,
- mission,
- skip_capacity_check=keeps_same_contribution_type,
- skip_weekly_capacity_check=keeps_same_contribution_type,
- weekly_capacity_at=instance.created_at,
- allow_existing_community_submission=keeps_same_contribution_type,
- # A type made non-submittable after the fact must not lock users
- # out of editing their existing pending/more-info submissions.
- skip_submittable_check=keeps_same_contribution_type,
- )
- if contribution_type_error is not None:
- return contribution_type_error
+ serializer = self.get_serializer(
+ instance,
+ data=request.data,
+ partial=partial,
+ )
+ serializer.is_valid(raise_exception=True)
+ was_more_info_needed = instance.state == 'more_info_needed'
+ response_snapshot = None
- project_contribution_id = (
- request.data.get('project_contribution')
- if 'project_contribution' in request.data
- else (instance.project_contribution_id if instance.project_contribution_id else None)
- )
- milestone_project_contribution, project_error = self._project_link_error(
- request.user,
- contribution_type,
- project_contribution_id,
- existing_project_contribution_id=instance.project_contribution_id,
- )
- if project_error is not None:
- return project_error
+ if was_more_info_needed:
+ response_payload = serializer.validated_data['more_info_response']
+ latest_request = (
+ SubmissionNote.objects
+ .select_for_update()
+ .select_related('user')
+ .filter(
+ submitted_contribution=instance,
+ is_proposal=False,
+ data__action='more_info',
+ )
+ .order_by('-created_at', '-id')
+ .first()
+ )
- notes_error = self._milestone_notes_error(
- contribution_type,
- serializer.validated_data.get('notes', instance.notes),
- )
- if notes_error is not None:
- return notes_error
+ latest_request_answered = latest_request and (
+ SubmissionMoreInfoResponse.objects.filter(
+ request_note=latest_request,
+ ).exists()
+ )
+ if latest_request and not latest_request_answered:
+ if response_payload['request_id'] != latest_request.id:
+ return Response(
+ {
+ 'more_info_response': {
+ 'request_id': [
+ 'This request is stale. Refresh the submission and respond to the latest request.'
+ ],
+ },
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+
+ note_data = latest_request.data or {}
+ request_message = (
+ note_data.get('staff_reply') or latest_request.message
+ )
+ response_snapshot = {
+ 'request_note': latest_request,
+ 'request_message': request_message,
+ 'requested_by': latest_request.user,
+ 'requested_at': latest_request.created_at,
+ }
+ else:
+ # Requests created before SubmissionNote tracking only
+ # survive in staff_reply. They use a null request_id and
+ # are snapshotted before that field is cleared.
+ if response_payload['request_id'] is not None:
+ if (
+ latest_request_answered
+ and response_payload['request_id'] == latest_request.id
+ ):
+ detail = (
+ 'This more-information request already has a response.'
+ )
+ else:
+ detail = (
+ 'This request is stale. Refresh the submission '
+ 'and respond to the latest request.'
+ )
+ return Response(
+ {
+ 'more_info_response': {
+ 'request_id': [detail],
+ },
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+ if not (instance.staff_reply or '').strip():
+ return Response(
+ {
+ 'more_info_response': {
+ 'request_id': [
+ 'There is no active more-information request to answer.'
+ ],
+ },
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+ response_snapshot = {
+ 'request_note': None,
+ 'request_message': instance.staff_reply,
+ 'requested_by': instance.reviewed_by,
+ 'requested_at': instance.reviewed_at,
+ }
+
+ contribution_type = (
+ serializer.validated_data.get('contribution_type')
+ or instance.contribution_type
+ )
+ mission = serializer.validated_data.get('mission', instance.mission)
+ contribution_type = (
+ ContributionType.objects
+ .select_related('category')
+ .prefetch_related('required_discord_roles')
+ .get(id=contribution_type.id)
+ )
+ if (
+ preflight_contribution_type_id is not None
+ and contribution_type.id != preflight_contribution_type_id
+ ):
+ return Response(
+ {
+ 'error': (
+ 'The submission changed while it was being edited. '
+ 'Refresh and try again.'
+ ),
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+ keeps_same_contribution_type = (
+ contribution_type.id == instance.contribution_type_id
+ )
+ contribution_type_error = self._validate_submission_contribution_type(
+ request.user,
+ contribution_type,
+ mission,
+ skip_capacity_check=keeps_same_contribution_type,
+ skip_weekly_capacity_check=keeps_same_contribution_type,
+ weekly_capacity_at=instance.created_at,
+ # Preserve legacy community pending-edit behavior, but a
+ # resubmission must satisfy current category authorization.
+ allow_existing_community_submission=(
+ keeps_same_contribution_type and not was_more_info_needed
+ ),
+ # A type made non-submittable after the fact must not lock users
+ # out of editing their existing pending/more-info submissions.
+ skip_submittable_check=keeps_same_contribution_type,
+ # Any outbound refresh happened before acquiring the row lock.
+ # Re-check only the freshly cached membership and roles here.
+ sync_discord_roles=False,
+ )
+ if contribution_type_error is not None:
+ return contribution_type_error
+
+ project_contribution_id = (
+ request.data.get('project_contribution')
+ if 'project_contribution' in request.data
+ else (
+ instance.project_contribution_id
+ if instance.project_contribution_id else None
+ )
+ )
+ milestone_project_contribution, project_error = self._project_link_error(
+ request.user,
+ contribution_type,
+ project_contribution_id,
+ existing_project_contribution_id=instance.project_contribution_id,
+ )
+ if project_error is not None:
+ return project_error
+
+ notes_error = self._milestone_notes_error(
+ contribution_type,
+ serializer.validated_data.get('notes', instance.notes),
+ )
+ if notes_error is not None:
+ return notes_error
+
+ # PATCH normally leaves evidence untouched. A more-info
+ # resubmission still has to validate the resulting stored snapshot
+ # against today's evidence, ownership, and duplicate rules.
+ if was_more_info_needed and 'evidence_items' not in request.data:
+ evidence_snapshot = list(
+ instance.evidence_items.values('id', 'description', 'url')
+ )
+ serializer._validate_evidence_items(
+ evidence_snapshot,
+ require_at_least_one=(
+ not is_milestone_contribution_type(contribution_type)
+ ),
+ contribution_type=contribution_type,
+ user=request.user,
+ exclude_submission_id=instance.id,
+ )
- with transaction.atomic():
locked_type = (
ContributionType.objects
.select_for_update()
@@ -1105,16 +1315,23 @@ def update(self, request, *args, **kwargs):
serializer.validated_data['project_contribution'] = None
serializer.validated_data['milestone_version'] = None
- # Update state back to pending and track edit time
instance.state = 'pending'
instance.last_edited_at = timezone.now()
- instance.staff_reply = '' # Clear previous staff reply
+ instance.staff_reply = ''
instance.gate_reviewed = False
instance.reviewed_by = None
instance.reviewed_at = None
self.perform_update(serializer)
+ if was_more_info_needed:
+ SubmissionMoreInfoResponse.objects.create(
+ submitted_contribution=instance,
+ responder=request.user,
+ message=response_payload['message'],
+ **response_snapshot,
+ )
+
SubmissionStateTransition.record(
instance,
SubmissionStateTransition.EVENT_EDITED,
@@ -1130,7 +1347,12 @@ def update(self, request, *args, **kwargs):
actor=request.user,
)
- return Response(serializer.data)
+ # Re-fetch so the response just created is included in the nested
+ # request history returned to the client.
+ response_instance = self.get_queryset().get(id=instance.id)
+ response_data = self.get_serializer(response_instance).data
+
+ return Response(response_data)
def destroy(self, request, *args, **kwargs):
"""
@@ -1445,13 +1667,18 @@ def _content_query(self, term):
converted_evidence = Evidence.objects.filter(
contribution_id=OuterRef('converted_contribution_id')
).filter(self._evidence_content_query(term))
+ submitter_responses = SubmissionMoreInfoResponse.objects.filter(
+ submitted_contribution_id=OuterRef('pk'),
+ message__icontains=term,
+ )
return (
Q(title__icontains=term) |
Q(notes__icontains=term) |
Q(converted_contribution__title__icontains=term) |
Q(converted_contribution__notes__icontains=term) |
Exists(submitted_evidence) |
- Exists(converted_evidence)
+ Exists(converted_evidence) |
+ Exists(submitter_responses)
)
def filter_search(self, queryset, name, value):
@@ -2375,6 +2602,15 @@ def get_queryset(self):
).select_related('user').order_by('-created_at', '-id'),
to_attr='more_info_request_notes',
),
+ Prefetch(
+ 'more_info_responses',
+ queryset=SubmissionMoreInfoResponse.objects.select_related(
+ 'request_note',
+ 'requested_by',
+ 'responder',
+ ).order_by('-created_at', '-id'),
+ to_attr='more_info_response_rows',
+ ),
Prefetch(
'review_proposals',
queryset=ReviewProposal.objects.filter(
diff --git a/backend/notifications/services.py b/backend/notifications/services.py
index 4a30c6e3..6d238de8 100644
--- a/backend/notifications/services.py
+++ b/backend/notifications/services.py
@@ -454,8 +454,8 @@ def notify_submission_reopened_for_steward(submission, *, kind, actor=None):
dedupe_marker = 'appeal'
elif kind == 'more_info_resubmitted':
event_slug = 'submission.more_info_resubmitted'
- title = 'More information resubmitted'
- body = f"{name} was updated by the submitter after your more-information request."
+ title = 'More-information response received'
+ body = f"The submitter responded to your more-information request for {name}."
edit_at = submission.last_edited_at or submission.updated_at
dedupe_marker = edit_at.isoformat() if edit_at else ''
else:
diff --git a/backend/notifications/tests.py b/backend/notifications/tests.py
index c9f1018b..54b5ea26 100644
--- a/backend/notifications/tests.py
+++ b/backend/notifications/tests.py
@@ -6,7 +6,7 @@
from django.utils import timezone
from rest_framework.test import APIClient
-from contributions.models import Category, ContributionType, SubmittedContribution
+from contributions.models import Category, ContributionType, Evidence, SubmittedContribution
from partners.models import Partner
from validators.models import Validator
@@ -490,11 +490,22 @@ def test_more_info_resubmission_notifies_assigned_steward(self):
assigned_to=self.steward_user,
gate_reviewed=True,
)
+ Evidence.objects.create(
+ submitted_contribution=submission,
+ description='Original evidence',
+ url='https://example.com/notification-resubmit-evidence',
+ )
self.client.force_authenticate(user=self.submitter)
response = self.client.patch(
f'/api/v1/submissions/{submission.id}/',
- {'notes': 'Added the requested evidence.'},
+ {
+ 'notes': 'Added the requested evidence.',
+ 'more_info_response': {
+ 'request_id': None,
+ 'message': 'Added the requested evidence.',
+ },
+ },
format='json',
)
diff --git a/backend/submissions_review.md b/backend/submissions_review.md
index e89cecbc..67832973 100644
--- a/backend/submissions_review.md
+++ b/backend/submissions_review.md
@@ -114,6 +114,16 @@ same structured notes rendered in submission cards, from
`is_more_info_resubmitted`, backed only by an append-only transition from
`more_info_needed` to `pending`.
+Each `more_info_requests[]` item also contains a nullable `response` object
+(`id`, `message`, `user`, `user_name`, `created_at`). A submitter answers the
+latest unanswered request by including
+`more_info_response: {request_id, message}` in the normal owner-scoped
+`PUT/PATCH /api/v1/submissions/{id}/` update. The response is required for the
+`more_info_needed -> pending` transition, limited to 1,000 characters, paired
+to that request cycle, and saved atomically with current submission validation.
+For a legacy request sourced only from `staff_reply`, `request_id` is `null`;
+no historical response is inferred.
+
Appeals and more-info resubmissions remain available to both AI stages. The
deterministic gate records an appeal as gate-reviewed but never auto-rejects
it, so an appeal always reaches a human reviewer.
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 58ea4d9a..f22befe0 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -392,7 +392,7 @@ const routes = {
'/validators/contribution/:id': ContributionPreview, // Validator-scoped contribution detail
'/contribution-type/:id': ContributionTypeDetail,
'/badge/:id': BadgeDetail,
- '/submit-contribution': SubmitContribution,
+ '/submit-contribution': SubmitContribution, // ?resubmit= owner-loads and prefills a normal create; takes precedence over ?mission and ?type
'/my-submissions': MySubmissions,
'/contributions/:id': EditSubmission,
'/metrics': Metrics,
@@ -536,8 +536,8 @@ In production the SPA (Amplify) and the API are on different hosts and `CSRF_COO
#### Steward Review Components (`src/components/`)
-- **`SubmissionCard.svelte`** - Submitter-only card used by My Submissions. It shows submission details, evidence, staff responses, awarded contributions, editing, and appeals without exposing steward-only state.
-- **`StewardSubmissionCard.svelte`** - Dedicated steward workspace for review outcomes, proposals, rubric scoring, internal notes, accepted-contribution edits, and permission-aware submission behavior. It marks `escalated_at` proposals and previews `escalation_threshold_points` conversion for reviewer-tier accepts.
+- **`SubmissionCard.svelte`** - Submitter-only card used by My Submissions. More-info cards keep Edit and add an inline required “What did you change?” direct-resubmit action; rejected cards keep Appeal and add a corrected-submission link to `/submit-contribution?resubmit=`. Request responses render in their own nested box, separate from original notes.
+- **`StewardSubmissionCard.svelte`** - Dedicated steward workspace for review outcomes, proposals, rubric scoring, internal notes, accepted-contribution edits, and permission-aware submission behavior. It marks `escalated_at` proposals, previews `escalation_threshold_points` conversion for reviewer-tier accepts, and renders each submitter response beneath its paired more-info request.
- **Contribution type review fields** - Contribution type payloads expose `requires_ai_review` and nullable `escalation_threshold_points`; the latter drives the card's advisory preview while the server remains authoritative.
- **Steward submission search** - `is:escalated` / `not:escalated` map to the `is_escalated` API filter. Tier 2+ stewards get an Escalated queue chip; tier 3 stewards also get an Apex queue chip for `status:accepted is:interesting`.
- **`AIReviewSummary.svelte`** - Compact AI-only proposal summary that exposes the proposed action, confidence, and expandable synthesis.
@@ -590,6 +590,7 @@ Reusable, data-driven display components that accept data via props. Used on Das
#### Portal Overview Components (`src/components/portal/`)
Investor-oriented home page (`routes/Overview.svelte`), top to bottom: hero → network activity (with portal contributors) → projects (`FeaturedBuilds`) → partner marquee.
+- **`ContributionGuidelines.svelte`** - Compact pre-flight submission card shared by the create form and the Projects contribution-type detail page. Per-type panels: Projects (slot counter, category router, quality bar, go-further tip), Intelligent Contract (`create-intelligent-contracts`; quality bar + strict note), Milestone (`milestones`; leads with a highlighted-project eligibility check via the `milestoneEligible` prop), and a minimal default state (weekly-window chip only). The slot counter is the single accent element: personalized ("You have used X of N Project slots this week", segment bar, soft warning at 0 left) when `user_weekly_submissions_remaining` is present, static otherwise. Post-submission process (More information / Rejection / Appeal) lives only in the collapsed "After you submit" accordion inside type panels. The `onRoute(slug)` prop makes the category-router names clickable (the create form wires it to `selectType`); without it they render as plain text (detail page). The create form renders the card as a sticky right rail at `xl` (max-height + internal scroll) and as a collapsed `` titled "Before you submit" above the form below `xl`, with the slot summary in the collapsed header. Project slug detection is centralized in `lib/contributionGuidelines.js` so historical type URLs keep working.
- **`HeroBanner.svelte`** - supports `compact` (thinner banner) and `socialStats={ x, telegram, discord }` (a discreet brand-logo follower-count cluster top-right, Overview only; each badge links out to X/Telegram/Discord). The CTA button reads "View"; a "See all" text link (→ `/gen-news`) sits bottom-right next to the carousel dots. Overview fetches `metricsAPI.getOverview()` for `socialStats`.
- **`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.
@@ -662,7 +663,8 @@ Investor-oriented home page (`routes/Overview.svelte`), top to bottom: hero →
- Uses VITE_RECAPTCHA_SITE_KEY from environment (falls back to test key)
- Honors optional per-user Monday-Sunday UTC contribution-type limits from `user_weekly_*` API fields and shows the user's remaining weekly capacity.
- New Milestones links can select only highlighted Projects contributions. `EditSubmission.svelte` passes the current submission ID so a pre-policy pending milestone keeps its existing unhighlighted link.
-- `EditSubmission.svelte` - Edit submitted contributions (supports URL and description evidence only - no file uploads)
+ - Rejected resubmission is prefill context only: `?resubmit=` clones date, title, notes, evidence values, type, mission, and linked project without evidence database IDs, then submits through the ordinary POST + reCAPTCHA path. The old rejected row remains untouched. Retired/full types and inactive/full missions preserve copied content but require a valid explicit selection; no silent fallback is allowed. A successful clone returns to `?submission=` for highlighting.
+- `EditSubmission.svelte` - Edit submitted contributions (supports URL and description evidence only - no file uploads). A more-info edit shows the steward request plus a distinct required response panel, sends `more_info_response: {request_id, message}`, and labels the action “Save and resubmit.”
- `ProfileEdit.svelte` - User profile editing (name and profile fields; node version shown read-only, Grafana-sourced)
- `Profile.svelte` - Public participant profile view
diff --git a/frontend/src/components/StewardSubmissionCard.svelte b/frontend/src/components/StewardSubmissionCard.svelte
index bda1f4a3..65e72f0f 100644
--- a/frontend/src/components/StewardSubmissionCard.svelte
+++ b/frontend/src/components/StewardSubmissionCard.svelte
@@ -1596,6 +1596,22 @@
{request.created_at ? ' on ' + formatDate(request.created_at) : ''}
{/if}
+ {#if request.response}
+
+
+
Submitter response
+ {#if request.response.created_at}
+
{formatDate(request.response.created_at)}
+ {/if}
+
+
+ {@html parseUserMarkdown(request.response.message)}
+
+ {#if request.response.user_name}
+
Responded by {request.response.user_name}
+ {/if}
+
+ {/if}
{/each}
diff --git a/frontend/src/components/SubmissionCard.svelte b/frontend/src/components/SubmissionCard.svelte
index 78394ceb..c6b7bafd 100644
--- a/frontend/src/components/SubmissionCard.svelte
+++ b/frontend/src/components/SubmissionCard.svelte
@@ -2,6 +2,7 @@
import { push } from 'svelte-spa-router';
import { format } from '../lib/dates.js';
import { parseMarkdown, parseUserMarkdown } from '../lib/markdownLoader.js';
+ import { submissionErrorMessage } from '../lib/submissionErrors.js';
import { showError, showSuccess } from '../lib/toastStore.js';
import Badge from './Badge.svelte';
import ContributionCard from './ContributionCard.svelte';
@@ -9,9 +10,10 @@
/** @type {{
* submission: Record,
- * onAppeal?: ((submissionId: string | number, reason: string) => Promise) | null
+ * onAppeal?: ((submissionId: string | number, reason: string) => Promise) | null,
+ * onResubmit?: ((submissionId: string | number, response: { request_id: number | null, message: string }) => Promise) | null
* }} */
- let { submission, onAppeal = null } = $props();
+ let { submission, onAppeal = null, onResubmit = null } = $props();
/** @type {Record} */
const STATE_STYLES = {
@@ -50,6 +52,10 @@
let appealReason = $state('');
let submittingAppeal = $state(false);
let copyingSubmissionId = $state(false);
+ let showingResubmitResponse = $state(false);
+ let moreInfoResponse = $state('');
+ let resubmitting = $state(false);
+ let resubmitError = $state('');
let stateStyle = $derived(STATE_STYLES[submission.state] || DEFAULT_STATE_STYLE);
let contributionTypeName = $derived(
@@ -75,6 +81,11 @@
(/** @type {Record} */ request) => request?.message
)
);
+ let activeMoreInfoRequest = $derived(
+ moreInfoRequests.find(
+ (/** @type {Record} */ request) => !request.response
+ ) || null
+ );
let showStaffResponse = $derived(Boolean(
submission.staff_reply &&
submission.state !== 'rejected' &&
@@ -139,10 +150,37 @@
}
}
+ async function handleMoreInfoResubmit() {
+ const message = moreInfoResponse.trim();
+ if (!onResubmit || !message || resubmitting) return;
+
+ resubmitting = true;
+ resubmitError = '';
+ try {
+ await onResubmit(submission.id, {
+ request_id: activeMoreInfoRequest?.id ?? null,
+ message
+ });
+ moreInfoResponse = '';
+ showingResubmitResponse = false;
+ } catch (error) {
+ resubmitError = submissionErrorMessage(
+ error,
+ 'We could not resubmit this contribution. Open Edit to review the current requirements.'
+ );
+ } finally {
+ resubmitting = false;
+ }
+ }
+
function editSubmission() {
const missionQuery = submission.mission?.id ? `?mission=${submission.mission.id}` : '';
push(`/contributions/${submission.id}${missionQuery}`);
}
+
+ function resubmitRejectedSubmission() {
+ push(`/submit-contribution?resubmit=${encodeURIComponent(submission.id)}`);
+ }
@@ -268,6 +306,19 @@
{request.user_name ? `Requested by ${request.user_name}` : 'Requested'}
{#if request.created_at}on {formatDate(request.created_at)}{/if}
+ {#if request.response}
+
+
+
Your response
+ {#if request.response.created_at}
+
{formatDate(request.response.created_at)}
+ {/if}
+
+
+ {@html parseUserMarkdown(request.response.message)}
+
+
+ {/if}
{/each}
@@ -313,6 +364,20 @@
{/if}
+
+
Submit a corrected contribution
+
+ Resubmit creates a new, editable contribution using these details. This rejected submission stays unchanged{submission.has_appeal ? '.' : ' and can still be appealed.'}
+
+
+ Resubmit
+
+
+
{#if submission.has_appeal}
@@ -355,7 +420,7 @@
Your appeal is under review
A steward will re-review your submission.
- {:else if submission.state === 'pending' || submission.state === 'more_info_needed'}
+ {:else if submission.state === 'pending'}
+ {:else if submission.state === 'more_info_needed'}
+
+
+
+ Edit
+
+ {#if onResubmit}
+ {
+ showingResubmitResponse = !showingResubmitResponse;
+ resubmitError = '';
+ }}
+ aria-expanded={showingResubmitResponse}
+ aria-controls="more-info-response-{submission.id}"
+ disabled={resubmitting}
+ class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ Resubmit
+
+ {/if}
+
+
+ {#if showingResubmitResponse}
+
+
+ What did you change?
+
+
+ Briefly tell the steward how you addressed the request. If the submission itself also needs edits, use Edit instead.
+
+
+
+
+ {#if resubmitError}
+
{resubmitError}
+ {/if}
+
+
{moreInfoResponse.length}/1000
+
+
+
+ {resubmitting ? 'Resubmitting...' : 'Send response and resubmit'}
+
+
+
+ {/if}
+
{/if}
diff --git a/frontend/src/components/portal/ContributionGuidelines.svelte b/frontend/src/components/portal/ContributionGuidelines.svelte
new file mode 100644
index 00000000..4597886f
--- /dev/null
+++ b/frontend/src/components/portal/ContributionGuidelines.svelte
@@ -0,0 +1,744 @@
+
+
+{#snippet checkIcon()}
+
+
+
+{/snippet}
+
+{#snippet slotCounter()}
+ {#if weeklyLimit !== null}
+
+ {#if slotsUsed !== null}
+
+ {#if slotsRemaining === 0}
+ You have used your {weeklyLimit} {slotNoun} for this week.
+ {:else}
+ You have used {slotsUsed} of {weeklyLimit} {slotNoun} this week
+ {/if}
+ Resets Monday 00:00 UTC
+
+
+ {#each Array(weeklyLimit) as _, i}
+
+ {/each}
+
+ {:else}
+
+ {weeklyLimit} new {submissionNoun} per user, each week
+ {WEEK_WINDOW}
+
+ {/if}
+
+ {/if}
+{/snippet}
+
+{#snippet categoryRouter()}
+
+
+
+
A standalone reusable contract
+
+ {#if onRoute}
+
onRoute('create-intelligent-contracts')}>Intelligent Contract
+ {:else}
+
Intelligent Contract
+ {/if}
+
+
+
An improvement to an accepted project
+
+ {#if onRoute}
+
onRoute('milestones')}>Milestone
+ {:else}
+
Milestone
+ {/if}
+
+
+
+ Milestones are open only for highlighted projects. Cosmetic changes do not qualify.
+
+
+{/snippet}
+
+{#snippet qualityList()}
+
+
Quality bar
+
+ {#each qualityBar as item}
+
+ {@render checkIcon()}
+ {item[0]} {item[1]}
+
+ {/each}
+
+ {#if panel === 'contract'}
+
Acceptance for this category is strict. Lightweight contracts are rejected.
+ {/if}
+
+{/snippet}
+
+{#snippet afterSubmit()}
+
+
+ After you submit: reviews, appeals, and slots
+
+
+
+
+
+
More information. We see a path to acceptance and need details. Update the existing submission. Does not use a slot.
+
Rejection. The use case or implementation falls short. Fixes or new evidence require a new submission, which uses a slot.
+
Appeal. Challenges the original decision only, with the work as it was submitted. Does not use a slot.
+
+
+{/snippet}
+
+{#snippet panelBody()}
+ {#key panel}
+
+ {#if panel === 'projects'}
+ {@render slotCounter()}
+ {@render categoryRouter()}
+ {@render qualityList()}
+
Go further: live demos, videos, and public posts earn extra points and speed up review.
+ {@render afterSubmit()}
+ {:else if panel === 'contract'}
+ {@render slotCounter()}
+ {@render qualityList()}
+ {@render afterSubmit()}
+ {:else if panel === 'milestone'}
+ {#if milestoneEligible === false}
+
+ You have no highlighted Project contributions yet.
+ Milestones can only be linked to a highlighted Project.
+
+ {:else}
+
+ Link a highlighted Project contribution, then describe what changed. Cosmetic changes do not qualify.
+
+ {/if}
+ {@render slotCounter()}
+ {@render afterSubmit()}
+ {:else if weeklyLimit !== null}
+ {@render slotCounter()}
+ {:else}
+
+
+
+
+
+ Weekly limits run {WEEK_WINDOW}
+
+ {/if}
+
+ {/key}
+{/snippet}
+
+{#snippet headerIcon()}
+
+{/snippet}
+
+{#if mobile}
+
+
+ {@render headerIcon()}
+
+ Before you submit
+ {mobileSummaryLine}
+
+
+
+
+
+
+ {#if panel !== 'general'}
+
{subtitle}
+ {/if}
+ {@render panelBody()}
+
+
+{:else}
+
+
+ {@render panelBody()}
+
+{/if}
+
+
diff --git a/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte b/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte
index c006e98b..2c87b28f 100644
--- a/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte
+++ b/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte
@@ -5,8 +5,11 @@
import { contributionsAPI, submissionsAPI } from "../../../lib/api.js";
import { getMissions } from "../../../lib/missionsStore.js";
import { authState } from "../../../lib/auth.js";
+ import { submissionErrorMessage } from "../../../lib/submissionErrors.js";
import { userStore } from "../../../lib/userStore";
+ import { isProjectContributionType } from "../../../lib/contributionGuidelines.js";
import ConfirmDialog from "../../ConfirmDialog.svelte";
+ import ContributionGuidelines from "../ContributionGuidelines.svelte";
import { parseMarkdown } from "../../../lib/markdownLoader.js";
import {
getAnalyticsContext,
@@ -22,6 +25,7 @@
missionId = null,
initialTypeId = null,
submission = null,
+ resubmitSource = null,
} = $props();
let submitting = $state(false);
@@ -33,6 +37,8 @@
let isBuilder = $derived(!!$userStore.user?.builder);
let isCreator = $derived(!!$userStore.user?.creator);
let editMode = $derived(Boolean(submission?.id));
+ let resubmitMode = $derived(!editMode && Boolean(resubmitSource?.id));
+ let cloneContextWarning = $state("");
// reCAPTCHA state
let recaptchaToken = $state("");
@@ -91,6 +97,11 @@
let originalContributionTypeId = $derived(
submission?.contribution_type ?? null,
);
+ let resubmitSourceProjectId = $derived(
+ resubmitSource?.project_contribution?.id ??
+ resubmitSource?.project_contribution ??
+ "",
+ );
let keepsOriginalContributionType = $derived(
editMode &&
selectedType &&
@@ -98,6 +109,7 @@
);
let hasLegacyCommunityEditAccess = $derived(
editMode &&
+ submission?.state !== "more_info_needed" &&
submission?.contribution_type_details?.category === "community",
);
let canAccessCurrentCategory = $derived(
@@ -113,7 +125,8 @@
hasCurrentCategoryRole ||
(
keepsOriginalContributionType &&
- selectedCategory === "community"
+ selectedCategory === "community" &&
+ hasLegacyCommunityEditAccess
),
);
let selectedMission = $state(null);
@@ -124,11 +137,18 @@
);
let selectionLocked = $derived(missionLocked || appealLocked);
let latestMoreInfoRequest = $derived(
- submission?.more_info_requests?.[0] || null,
+ submission?.more_info_requests?.find(
+ (/** @type {Record} */ request) => !request?.response,
+ ) ||
+ submission?.more_info_requests?.[0] ||
+ null,
);
let reviewFeedback = $derived(
latestMoreInfoRequest?.message || submission?.staff_reply || "",
);
+ let requiresMoreInfoResponse = $derived(
+ editMode && submission?.state === "more_info_needed",
+ );
let acceptedProjects = $state([]);
let loadingProjects = $state(false);
let projectsError = $state(false);
@@ -145,6 +165,7 @@
title: "",
notes: "",
});
+ let moreInfoResponse = $state("");
// Evidence Slots
let evidenceSlots = $state([]);
@@ -198,6 +219,15 @@
};
}
+ /** @param {Array> | null | undefined} items */
+ function evidenceItemsWithoutIds(items) {
+ return (items || []).map((/** @type {Record} */ item) => ({
+ description: item?.description || "",
+ url: item?.url || "",
+ url_type: item?.url_type || null,
+ }));
+ }
+
function evidenceMatchesRequiredType(slot, contributionType) {
const requiredTypes = contributionType?.required_evidence_url_types || [];
if (!slot?.selectedType || requiredTypes.length === 0) return false;
@@ -243,26 +273,27 @@
let disposed = false;
async function initializeForm() {
- if (editMode) {
+ if (editMode || resubmitMode) {
+ const source = editMode ? submission : resubmitSource;
selectedCategory =
- submission.contribution_type_details?.category || "builder";
+ source.contribution_type_details?.category || "builder";
selectedMission =
- submission.mission?.id ?? submission.mission ?? null;
+ source.mission?.id ?? source.mission ?? null;
selectedProject =
- submission.project_contribution?.id ??
- submission.project_contribution ??
+ source.project_contribution?.id ??
+ source.project_contribution ??
"";
searchQuery =
- submission.contribution_type_name ||
- submission.contribution_type_details?.name ||
+ source.contribution_type_name ||
+ source.contribution_type_details?.name ||
"";
formData = {
- contribution_type: submission.contribution_type || "",
+ contribution_type: source.contribution_type || "",
contribution_date:
- submission.contribution_date?.split("T")[0] ||
+ source.contribution_date?.split("T")[0] ||
new Date().toISOString().split("T")[0],
- title: submission.title || "",
- notes: submission.notes || "",
+ title: source.title || "",
+ notes: source.notes || "",
};
}
@@ -343,7 +374,127 @@
}
}
}
- // Handle pre-selection from URL params
+ // Handle rejected-submission cloning before ordinary URL
+ // preselection. The cloned text/evidence always stays populated,
+ // while an unavailable type or mission is deliberately left for
+ // the user to replace.
+ } else if (resubmitMode) {
+ const clonedEvidence = evidenceItemsWithoutIds(
+ resubmitSource.evidence_items,
+ );
+ let sourceType = types.find(
+ (type) =>
+ String(type.id) === String(resubmitSource.contribution_type),
+ );
+ if (!sourceType) {
+ try {
+ const response = await contributionsAPI.getContributionType(
+ resubmitSource.contribution_type,
+ );
+ sourceType = response.data || null;
+ if (sourceType) {
+ types = [...types, sourceType];
+ allEvidenceUrlTypes = collectEvidenceUrlTypes(types);
+ }
+ } catch (err) {
+ sourceType = null;
+ }
+ }
+
+ partitionEvidenceForType(sourceType, clonedEvidence);
+ if (!sourceType) {
+ selectedType = null;
+ selectedMission = null;
+ selectedMissionData = null;
+ formData.contribution_type = "";
+ searchQuery = "";
+ showTypeDropdown = true;
+ cloneContextWarning = `The original contribution type, ${resubmitSource.contribution_type_name || "Unknown type"}, is no longer available. Choose a current type to continue.`;
+ } else {
+ selectedCategory = sourceType.category || selectedCategory;
+ const sourceMissionId =
+ resubmitSource.mission?.id ?? resubmitSource.mission ?? null;
+
+ if (sourceMissionId) {
+ let sourceMission = missions.find(
+ (mission) => String(mission.id) === String(sourceMissionId),
+ );
+ if (!sourceMission) {
+ try {
+ const response = await contributionsAPI.getMission(
+ sourceMissionId,
+ );
+ sourceMission = response.data || null;
+ } catch (err) {
+ sourceMission = null;
+ }
+ }
+
+ if (
+ sourceMission &&
+ String(sourceMission.contribution_type) ===
+ String(sourceType.id) &&
+ isMissionSubmittable(sourceMission) &&
+ !isTypeFull(sourceType)
+ ) {
+ selectedType = sourceType;
+ selectedMission = sourceMission.id;
+ selectedMissionData = sourceMission;
+ formData.contribution_type = sourceType.id;
+ searchQuery = sourceMission.name;
+ } else {
+ selectedType = null;
+ selectedMission = null;
+ selectedMissionData = null;
+ formData.contribution_type = "";
+ searchQuery = "";
+ showTypeDropdown = true;
+ const missionName =
+ sourceMission?.name ||
+ resubmitSource.mission?.name ||
+ "The original mission";
+ cloneContextWarning = isTypeFull(sourceType)
+ ? `${sourceType.name} is currently full. Your copied details are intact; choose an available contribution type or mission.`
+ : `${missionName} is no longer accepting submissions. Your copied details are intact; choose a current contribution type or mission.`;
+ }
+ } else if (sourceType.is_submittable && !isTypeFull(sourceType)) {
+ selectedType = sourceType;
+ selectedMission = null;
+ selectedMissionData = null;
+ formData.contribution_type = sourceType.id;
+ searchQuery = sourceType.name;
+ } else {
+ selectedType = null;
+ selectedMission = null;
+ selectedMissionData = null;
+ formData.contribution_type = "";
+ searchQuery = "";
+ showTypeDropdown = true;
+ cloneContextWarning = isTypeFull(sourceType)
+ ? `${sourceType.name} is currently full. Your copied details are intact; choose an available contribution type or mission.`
+ : `${sourceType.name} no longer accepts direct submissions. Your copied details are intact; choose a current contribution type or mission.`;
+ }
+
+ if (selectedType && isMilestoneType(selectedType)) {
+ const sourceProjectId =
+ resubmitSource.project_contribution?.id ??
+ resubmitSource.project_contribution ??
+ "";
+ const projectStillAvailable = acceptedProjects.some(
+ (project) => String(project.id) === String(sourceProjectId),
+ );
+ selectedProject = projectStillAvailable
+ ? String(sourceProjectId)
+ : "";
+ if (sourceProjectId && !projectStillAvailable) {
+ const projectWarning =
+ "The originally linked project is no longer available for a milestone submission. Select one of your current highlighted projects.";
+ cloneContextWarning = cloneContextWarning
+ ? `${cloneContextWarning} ${projectWarning}`
+ : projectWarning;
+ }
+ }
+ }
} else if (missionId) {
// Load the specific mission and pre-select it
try {
@@ -444,7 +595,9 @@
} catch (err) {
error = editMode
? "Failed to load this submission's form."
- : "Failed to load contribution categories.";
+ : resubmitMode
+ ? "Failed to prepare this rejected submission. Please try again."
+ : "Failed to load contribution categories.";
console.error(err);
} finally {
if (!disposed) loadingTypes = false;
@@ -646,7 +799,19 @@
}
function isProjectType(type) {
- return ["projects", "projects-and-milestones", "projects-milestones", "project-milestone"].includes(type?.slug);
+ return isProjectContributionType(type);
+ }
+
+ /** @param {Record | null} type */
+ function clonedProjectForType(type) {
+ if (!resubmitMode || !isMilestoneType(type) || !resubmitSourceProjectId) {
+ return "";
+ }
+ return acceptedProjects.some(
+ (project) => String(project.id) === String(resubmitSourceProjectId),
+ )
+ ? String(resubmitSourceProjectId)
+ : "";
}
async function loadAcceptedProjects() {
@@ -667,6 +832,12 @@
}
}
+ // null while loading or after a fetch error so the guidance panel never
+ // tells a builder they are ineligible off transient state.
+ let milestoneEligible = $derived(
+ loadingProjects || projectsError ? null : acceptedProjects.length > 0,
+ );
+
let selectedProjectData = $derived(
acceptedProjects.find((project) => String(project.id) === String(selectedProject)) || null
);
@@ -833,6 +1004,11 @@
showProjectDropdown = false;
}
+ function routeToType(slug) {
+ const target = types.find((type) => type.slug === slug);
+ if (target) selectType(target);
+ }
+
function selectType(t) {
if (selectionLocked) return;
if (!isOriginalType(t) && isTypeFull(t)) {
@@ -852,11 +1028,12 @@
selectedType = t;
selectedMission = null;
selectedMissionData = null;
- selectedProject = "";
+ selectedProject = clonedProjectForType(t);
showProjectDropdown = false;
formData.contribution_type = t.id;
showTypeDropdown = false;
searchQuery = t.name;
+ if (resubmitMode) cloneContextWarning = "";
if (error === "Please select a contribution type") error = "";
}
@@ -885,11 +1062,12 @@
selectedType = item.parentType;
selectedMission = item.data.id;
selectedMissionData = item.data;
- selectedProject = "";
+ selectedProject = clonedProjectForType(item.parentType);
showProjectDropdown = false;
formData.contribution_type = item.parentType.id;
showTypeDropdown = false;
searchQuery = item.data.name;
+ if (resubmitMode) cloneContextWarning = "";
if (error === "Please select a contribution type") error = "";
}
}
@@ -1296,6 +1474,11 @@
return;
}
+ if (requiresMoreInfoResponse && !moreInfoResponse.trim()) {
+ error = "Tell the steward what changed before resubmitting.";
+ return;
+ }
+
if (!formData.contribution_type) {
trackEvent(editMode ? "contribution_edit_error" : "contribution_submit_error", getAnalyticsContext({
surface: editMode ? "edit_form" : "form",
@@ -1434,6 +1617,12 @@
title: formData.title,
notes: formData.notes,
};
+ if (requiresMoreInfoResponse) {
+ submissionData.more_info_response = {
+ request_id: latestMoreInfoRequest?.id ?? null,
+ message: moreInfoResponse.trim(),
+ };
+ }
if (!editMode) submissionData.recaptcha = currentRecaptchaToken;
if (isMilestoneType(selectedType)) {
@@ -1458,11 +1647,9 @@
// Send evidence inline with the submission (atomic creation)
submissionData.evidence_items = allEvidence;
- if (editMode) {
- await api.put(`/submissions/${submission.id}/`, submissionData);
- } else {
- await api.post("/submissions/", submissionData);
- }
+ const savedResponse = editMode
+ ? await api.put(`/submissions/${submission.id}/`, submissionData)
+ : await api.post("/submissions/", submissionData);
if (editMode) {
trackEvent("contribution_edit_success", getAnalyticsContext({
@@ -1492,10 +1679,19 @@
sessionStorage.setItem(
"submissionUpdateSuccess",
editMode
- ? "Your submission has been saved successfully."
- : "Your contribution has been submitted successfully and is pending review.",
+ ? requiresMoreInfoResponse
+ ? "Your response was sent and the submission is back in review."
+ : "Your submission has been saved successfully."
+ : resubmitMode
+ ? "Your corrected contribution has been submitted and is pending review."
+ : "Your contribution has been submitted successfully and is pending review.",
+ );
+ const createdSubmissionId = resubmitMode ? savedResponse?.data?.id : null;
+ push(
+ createdSubmissionId
+ ? `/my-submissions?submission=${encodeURIComponent(createdSubmissionId)}`
+ : "/my-submissions",
);
- push("/my-submissions");
} catch (err) {
trackEvent(editMode ? "contribution_edit_error" : "contribution_submit_error", getAnalyticsContext({
surface: editMode ? "edit_form" : "form",
@@ -1507,24 +1703,13 @@
error = Array.isArray(err.response.data.recaptcha)
? err.response.data.recaptcha[0]
: err.response.data.recaptcha;
- } else if (err.response?.data?.evidence_items) {
- // Parse evidence validation errors from backend
- const evidenceErrors = err.response.data.evidence_items;
- if (Array.isArray(evidenceErrors) && evidenceErrors.length > 0) {
- const first = evidenceErrors[0];
- error = first.message || JSON.stringify(first);
- } else {
- error = typeof evidenceErrors === "string"
- ? evidenceErrors
- : JSON.stringify(evidenceErrors);
- }
} else {
- error =
- err.response?.data?.error ||
- err.response?.data?.detail ||
- (editMode
+ error = submissionErrorMessage(
+ err,
+ editMode
? "Failed to save submission"
- : "Failed to submit contribution");
+ : "Failed to submit contribution",
+ );
}
if (!editMode && recaptchaWidgetId !== null && window.grecaptcha) {
@@ -1589,7 +1774,8 @@
{#if editMode}
{/if}
+
+ {#if requiresMoreInfoResponse}
+
+
+ What did you change?
+
+
+ This response is sent separately from your original submission notes, so the steward can see exactly how you addressed the request.
+
+
+
+ {moreInfoResponse.length}/1000
+
+
+ {/if}
+ {:else if resubmitMode}
+
+
+ {#if resubmitSource.staff_reply}
+
+ Original rejection reason
+
+ {@html parseMarkdown(resubmitSource.staff_reply)}
+
+
+ {/if}
+
+ {#if cloneContextWarning}
+
+
+
+
+ {cloneContextWarning}
+
+ {/if}
{:else}
{/if}
+ {#if !editMode}
+
+
+
+ {/if}
+
+
+ {#if !editMode}
+
+ {/if}
.submit-page-title {
+ grid-column: 1;
+ grid-row: 1;
+ }
+
+ .submit-form-shell.with-guidelines > form {
+ grid-column: 1;
+ grid-row: 2;
+ min-width: 0;
+ }
+
+ .desktop-guidelines-slot {
+ grid-column: 2;
+ grid-row: 2;
+ max-height: calc(100vh - 48px);
+ min-width: 0;
+ overflow-y: auto;
+ position: sticky;
+ top: 24px;
+ }
+ }
+
.submit-panel {
border-color: rgba(0, 0, 0, 0.055);
box-shadow:
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index 0e57aa0e..8ad8371a 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -186,6 +186,14 @@ export const contributionsAPI = {
// API endpoints for the submitter-side submission flows
export const submissionsAPI = {
appeal: (id, reason) => api.post(`/submissions/${id}/appeal/`, { reason }),
+ /**
+ * @param {string | number} id
+ * @param {{ request_id: number | null, message: string }} response
+ */
+ respondToMoreInfo: (id, response) => api.patch(
+ `/submissions/${id}/`,
+ { more_info_response: response }
+ ),
/** @param {string | null} submissionId */
getAcceptedProjects: (submissionId = null) =>
api.get('/submissions/accepted-projects/', {
diff --git a/frontend/src/lib/contributionGuidelines.js b/frontend/src/lib/contributionGuidelines.js
new file mode 100644
index 00000000..076c9399
--- /dev/null
+++ b/frontend/src/lib/contributionGuidelines.js
@@ -0,0 +1,16 @@
+const PROJECT_CONTRIBUTION_TYPE_SLUGS = new Set([
+ 'projects',
+ 'projects-and-milestones',
+ 'projects-milestones',
+ 'project-milestone',
+]);
+
+/**
+ * Keep legacy project slugs recognizable while old contribution types remain
+ * addressable from historical submissions.
+ *
+ * @param {{ slug?: string } | null | undefined} contributionType
+ */
+export function isProjectContributionType(contributionType) {
+ return PROJECT_CONTRIBUTION_TYPE_SLUGS.has(contributionType?.slug);
+}
diff --git a/frontend/src/lib/submissionErrors.js b/frontend/src/lib/submissionErrors.js
new file mode 100644
index 00000000..63076f09
--- /dev/null
+++ b/frontend/src/lib/submissionErrors.js
@@ -0,0 +1,61 @@
+/**
+ * Return the first useful message from DRF's nested validation-error shapes.
+ *
+ * @param {unknown} value
+ * @returns {string}
+ */
+function firstErrorMessage(value) {
+ if (typeof value === 'string') return value.trim();
+
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const message = firstErrorMessage(item);
+ if (message) return message;
+ }
+ return '';
+ }
+
+ if (value && typeof value === 'object') {
+ const record = /** @type {Record} */ (value);
+ if (typeof record.message === 'string') {
+ const message = record.message.trim();
+ if (message) return message;
+ }
+
+ for (const nestedValue of Object.values(record)) {
+ const message = firstErrorMessage(nestedValue);
+ if (message) return message;
+ }
+ }
+
+ return '';
+}
+
+/**
+ * Extract a submitter-facing message from an API or network error.
+ *
+ * @param {any} error
+ * @param {string} fallback
+ * @returns {string}
+ */
+export function submissionErrorMessage(error, fallback) {
+ const responseData = error?.response?.data || {};
+ const fields = [
+ responseData.more_info_response,
+ responseData.evidence_items,
+ responseData.error,
+ responseData.detail,
+ ];
+
+ for (const field of fields) {
+ const message = firstErrorMessage(field);
+ if (message) return message;
+ }
+
+ if (!error?.response && typeof error?.message === 'string') {
+ const message = error.message.trim();
+ if (message) return message;
+ }
+
+ return fallback;
+}
diff --git a/frontend/src/routes/ContributionTypeDetail.svelte b/frontend/src/routes/ContributionTypeDetail.svelte
index 0a918c21..74807a47 100644
--- a/frontend/src/routes/ContributionTypeDetail.svelte
+++ b/frontend/src/routes/ContributionTypeDetail.svelte
@@ -8,7 +8,9 @@
import { getMissions } from '../lib/missionsStore.js';
import HighlightsSlider from '../components/portal/HighlightsSlider.svelte';
import PortalContributionCard from '../components/portal/PortalContributionCard.svelte';
+ import ContributionGuidelines from '../components/portal/ContributionGuidelines.svelte';
import { getCategoryButtonStyle, getCategoryGradientStyle } from '../lib/categoryPresentation.js';
+ import { isProjectContributionType } from '../lib/contributionGuidelines.js';
import { visibleContributions } from '../lib/hiddenContributions.js';
import { userStore } from '../lib/userStore.js';
import { hasReadOnlyRoleSectionAccess } from '../lib/roleState.js';
@@ -73,6 +75,7 @@
let allContributionsPath = $derived(
`/all-contributions?category=${explorerCategory}&type=${params.id}`
);
+ let hasProjectGuidelines = $derived(isProjectContributionType(contributionType));
function formatDate(dateString) {
if (!dateString) return 'Never';
@@ -324,6 +327,10 @@
{/if}
+ {#if hasProjectGuidelines}
+
+ {/if}
+
{#if missions.length > 0}
diff --git a/frontend/src/routes/MySubmissions.svelte b/frontend/src/routes/MySubmissions.svelte
index 98d3da0f..d71fc4b2 100644
--- a/frontend/src/routes/MySubmissions.svelte
+++ b/frontend/src/routes/MySubmissions.svelte
@@ -265,25 +265,53 @@
push('/submit-contribution');
}
+ /**
+ * @param {string | number} submissionId
+ * @param {Record
} updatedSubmission
+ */
+ function reconcileUpdatedSubmission(submissionId, updatedSubmission) {
+ const idx = submissions.findIndex(s => s.id === submissionId);
+ if (idx === -1) return;
+
+ if (stateFilter && stateFilter !== updatedSubmission.state) {
+ submissions = submissions.filter(s => s.id !== submissionId);
+ totalCount = Math.max(0, totalCount - 1);
+ return;
+ }
+
+ submissions[idx] = updatedSubmission;
+ submissions = [...submissions];
+ }
+
async function handleAppeal(submissionId, reason) {
try {
const response = await submissionsAPI.appeal(submissionId, reason);
- const idx = submissions.findIndex(s => s.id === submissionId);
- if (idx !== -1) {
- if (stateFilter && stateFilter !== response.data.state) {
- submissions = submissions.filter(s => s.id !== submissionId);
- totalCount = Math.max(0, totalCount - 1);
- } else {
- submissions[idx] = response.data;
- submissions = [...submissions];
- }
- }
+ reconcileUpdatedSubmission(submissionId, response.data);
showSuccess('Appeal submitted. A steward will re-review your submission.');
} catch (err) {
showError(err.response?.data?.error || 'Failed to submit appeal');
throw err;
}
}
+
+ /**
+ * @param {string | number} submissionId
+ * @param {{ request_id: number | null, message: string }} moreInfoResponse
+ */
+ async function handleMoreInfoResubmit(submissionId, moreInfoResponse) {
+ const submission = submissions.find(s => s.id === submissionId);
+ if (!submission) {
+ throw new Error('Submission is no longer visible. Refresh and try again.');
+ }
+
+ const response = await submissionsAPI.respondToMoreInfo(
+ submissionId,
+ moreInfoResponse
+ );
+ reconcileUpdatedSubmission(submissionId, response.data);
+ showSuccess('Your response was sent and the submission is back in review.');
+ return response.data;
+ }
@@ -375,6 +403,7 @@
{/each}
diff --git a/frontend/src/routes/SubmitContribution.svelte b/frontend/src/routes/SubmitContribution.svelte
index 2f78dcec..52be9f9c 100644
--- a/frontend/src/routes/SubmitContribution.svelte
+++ b/frontend/src/routes/SubmitContribution.svelte
@@ -1,24 +1,86 @@
- {#if !authChecked}
+ {#if !authChecked || resubmitLoading || (resubmitId && $authState.isAuthenticated && resubmitResultKey !== activeResubmitKey)}
+ {:else if resubmitError}
+
+
+
+
+
+
+
+
Unable to start resubmission
+
{resubmitError}
+
+ {#if resubmitCanRetry}
+ {
+ if (resubmitId) void loadResubmitSource(resubmitId);
+ }}
+ class="inline-flex min-h-10 items-center justify-center rounded-full bg-[#1a1c1d] px-5 font-['Switzer'] text-[14px] font-medium text-white hover:bg-black"
+ >
+ Try again
+
+ {/if}
+ push("/my-submissions")}
+ class="inline-flex min-h-10 items-center justify-center rounded-full bg-[#f5f5f5] px-5 font-['Switzer'] text-[14px] font-medium text-[#1a1c1d] hover:bg-[#eaeaea]"
+ >
+ Back to my submissions
+
+
+
+
{:else}
-
+
{/if}
diff --git a/frontend/src/tests/ContributionGuidelines.test.js b/frontend/src/tests/ContributionGuidelines.test.js
new file mode 100644
index 00000000..89808ec3
--- /dev/null
+++ b/frontend/src/tests/ContributionGuidelines.test.js
@@ -0,0 +1,140 @@
+import { fireEvent, render, screen } from '@testing-library/svelte/svelte5';
+import { describe, expect, it, vi } from 'vitest';
+import ContributionGuidelines from '../components/portal/ContributionGuidelines.svelte';
+
+const projectType = {
+ id: 7,
+ name: 'Projects',
+ slug: 'projects',
+ max_submissions_per_user_per_week: 2,
+};
+
+describe('ContributionGuidelines', () => {
+ it('shows only a minimal pre-flight card before a contribution type is selected', () => {
+ render(ContributionGuidelines);
+
+ expect(screen.getByRole('heading', { name: 'Before you submit' })).toBeTruthy();
+ expect(screen.getByText('Weekly limits run Monday 00:00 to Sunday 23:59 UTC')).toBeTruthy();
+ expect(screen.queryByText('Quality bar')).toBeNull();
+ expect(screen.queryByText('After you submit: reviews, appeals, and slots')).toBeNull();
+ expect(screen.queryByText('More information.', { exact: false })).toBeNull();
+ });
+
+ it('shows the Project panel with the static limit when no personal usage data exists', () => {
+ render(ContributionGuidelines, { props: { contributionType: projectType } });
+
+ expect(screen.getByRole('heading', { name: 'Before you submit a Project' })).toBeTruthy();
+ expect(screen.getByText('2 new Project submissions per user, each week')).toBeTruthy();
+ expect(screen.getByText('Monday 00:00 to Sunday 23:59 UTC')).toBeTruthy();
+ expect(screen.getByText('Quality bar')).toBeTruthy();
+ expect(screen.getByText('Solves a real trust problem.')).toBeTruthy();
+ expect(
+ screen.getByText('Milestones are open only for highlighted projects. Cosmetic changes do not qualify.'),
+ ).toBeTruthy();
+ });
+
+ it('personalizes the slot counter when weekly usage data is available', () => {
+ render(ContributionGuidelines, {
+ props: {
+ contributionType: { ...projectType, user_weekly_submissions_remaining: 1 },
+ },
+ });
+
+ expect(screen.getByText('You have used 1 of 2 Project slots this week')).toBeTruthy();
+ expect(screen.getByText('Resets Monday 00:00 UTC')).toBeTruthy();
+ expect(screen.getByRole('img', { name: '1 of 2 weekly slots used' })).toBeTruthy();
+ });
+
+ it('switches the counter to a warning when no weekly slots remain', () => {
+ render(ContributionGuidelines, {
+ props: {
+ contributionType: { ...projectType, user_weekly_submissions_remaining: 0 },
+ },
+ });
+
+ expect(screen.getByText('You have used your 2 Project slots for this week.')).toBeTruthy();
+ expect(screen.getByText('Resets Monday 00:00 UTC')).toBeTruthy();
+ });
+
+ it('routes to the sibling categories when the router entries are clicked', async () => {
+ const onRoute = vi.fn();
+ render(ContributionGuidelines, {
+ props: { contributionType: projectType, onRoute },
+ });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Intelligent Contract' }));
+ expect(onRoute).toHaveBeenCalledWith('create-intelligent-contracts');
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Milestone' }));
+ expect(onRoute).toHaveBeenCalledWith('milestones');
+ });
+
+ it('renders the router entries as plain text without an onRoute callback', () => {
+ render(ContributionGuidelines, { props: { contributionType: projectType } });
+
+ expect(screen.queryByRole('button', { name: 'Milestone' })).toBeNull();
+ expect(screen.getByText('Milestone')).toBeTruthy();
+ });
+
+ it('keeps the post-submission process collapsed behind an accordion', async () => {
+ render(ContributionGuidelines, { props: { contributionType: projectType } });
+
+ const accordion = screen
+ .getByText('After you submit: reviews, appeals, and slots')
+ .closest('details');
+ expect(accordion.open).toBe(false);
+
+ await fireEvent.click(screen.getByText('After you submit: reviews, appeals, and slots'));
+ expect(accordion.open).toBe(true);
+ expect(screen.getByText('More information.')).toBeTruthy();
+ expect(screen.getByText('Rejection.')).toBeTruthy();
+ expect(screen.getByText('Appeal.')).toBeTruthy();
+ });
+
+ it('shows the Intelligent Contract quality bar for the contract type', () => {
+ render(ContributionGuidelines, {
+ props: {
+ contributionType: { id: 9, name: 'Intelligent Contract', slug: 'create-intelligent-contracts' },
+ },
+ });
+
+ expect(
+ screen.getByRole('heading', { name: 'Before you submit an Intelligent Contract' }),
+ ).toBeTruthy();
+ expect(screen.getByText('Not a learning exercise.')).toBeTruthy();
+ expect(
+ screen.getByText('Acceptance for this category is strict. Lightweight contracts are rejected.'),
+ ).toBeTruthy();
+ expect(screen.queryByText('Solves a real trust problem.')).toBeNull();
+ });
+
+ it('leads the Milestone panel with an eligibility warning when the user has no highlighted projects', () => {
+ render(ContributionGuidelines, {
+ props: {
+ contributionType: { id: 10, name: 'Milestones', slug: 'milestones' },
+ milestoneEligible: false,
+ },
+ });
+
+ expect(screen.getByRole('heading', { name: 'Before you submit a Milestone' })).toBeTruthy();
+ expect(screen.getByText('You have no highlighted Project contributions yet.')).toBeTruthy();
+ });
+
+ it('renders the mobile guidance as a collapsed accordion with the slot counter in the header', async () => {
+ render(ContributionGuidelines, {
+ props: {
+ contributionType: { ...projectType, user_weekly_submissions_remaining: 1 },
+ mobile: true,
+ },
+ });
+
+ const disclosure = screen.getByText('Before you submit').closest('details');
+ expect(disclosure).toBeTruthy();
+ expect(disclosure.open).toBe(false);
+ expect(screen.getByText('1 of 2 Project slots used this week')).toBeTruthy();
+
+ await fireEvent.click(screen.getByText('Before you submit'));
+ expect(disclosure.open).toBe(true);
+ expect(screen.getByText('Quality bar')).toBeTruthy();
+ });
+});
diff --git a/frontend/src/tests/EditSubmission.test.js b/frontend/src/tests/EditSubmission.test.js
index 12689ef6..99042d40 100644
--- a/frontend/src/tests/EditSubmission.test.js
+++ b/frontend/src/tests/EditSubmission.test.js
@@ -60,7 +60,7 @@ vi.mock('../lib/userStore.js', async () => {
userStore: readable({
user: {
address: '0xcommunity',
- builder: false,
+ builder: true,
validator: false,
creator: false,
twitter_connection: null,
@@ -331,6 +331,53 @@ describe('EditSubmission', () => {
expect(push).toHaveBeenCalledWith('/my-submissions');
});
+ it('sends a required response separately when saving a more-info edit', async () => {
+ renderEditor(makeSubmission({
+ state: 'more_info_needed',
+ contribution_type: builderType.id,
+ contribution_type_name: builderType.name,
+ contribution_type_details: {
+ id: builderType.id,
+ name: builderType.name,
+ slug: builderType.slug,
+ category: builderType.category
+ },
+ staff_reply: 'Add release documentation.',
+ more_info_requests: [{
+ id: 37,
+ message: 'Add release documentation.',
+ user_name: 'Builder Steward',
+ created_at: '2026-07-19T12:00:00Z',
+ response: null
+ }]
+ }));
+
+ expect(await screen.findByText('Changes requested')).toBeTruthy();
+ expect(screen.getByText('Add release documentation.')).toBeTruthy();
+ const typeInput = await screen.findByDisplayValue('Builder Project');
+ await waitFor(() => expect(typeInput.disabled).toBe(false));
+ const responseInput = screen.getByRole('textbox', { name: 'What did you change?' });
+ expect(screen.getByRole('button', { name: 'Save and resubmit' })).toBeTruthy();
+
+ await fireEvent.input(responseInput, {
+ target: { value: ' Added release and setup documentation. ' }
+ });
+ await fireEvent.click(screen.getByRole('button', { name: 'Save and resubmit' }));
+
+ await waitFor(() => {
+ expect(mocks.api.put).toHaveBeenCalledTimes(1);
+ });
+ const [, payload] = mocks.api.put.mock.calls[0];
+ expect(payload.more_info_response).toEqual({
+ request_id: 37,
+ message: 'Added release and setup documentation.'
+ });
+ expect(payload.notes).toBe('Hosted the weekly call and published the recording.');
+ expect(payload.evidence_items[0]).toMatchObject({ id: 81 });
+ expect(payload).not.toHaveProperty('recaptcha');
+ expect(sessionStorage.getItem('submissionUpdateSuccess')).toContain('back in review');
+ });
+
it('removes an editable submission from the branded confirmation dialog', async () => {
renderEditor();
await screen.findByRole('button', { name: 'Save changes' });
diff --git a/frontend/src/tests/MissionBrowsing.test.js b/frontend/src/tests/MissionBrowsing.test.js
index 4db084c2..283d08a8 100644
--- a/frontend/src/tests/MissionBrowsing.test.js
+++ b/frontend/src/tests/MissionBrowsing.test.js
@@ -129,6 +129,24 @@ describe('historical mission browsing', () => {
expect(mocks.push).toHaveBeenCalledWith('/mission/12');
});
+ it('shows the submission guidance panel on the Projects type page', async () => {
+ mocks.getContributionType.mockResolvedValue({
+ data: {
+ ...contributionType,
+ name: 'Projects',
+ slug: 'projects',
+ max_submissions_per_user_per_week: 2,
+ },
+ });
+
+ render(ContributionTypeDetail, { props: { params: { id: '7' } } });
+
+ expect(
+ await screen.findByRole('heading', { name: 'Before you submit a Project' }),
+ ).toBeTruthy();
+ expect(screen.getByText('Monday 00:00 to Sunday 23:59 UTC')).toBeTruthy();
+ });
+
it('offers ended missions as filters in all contributions', async () => {
window.history.replaceState({}, '', '/all-contributions?type=7&mission=12');
render(AllContributions);
diff --git a/frontend/src/tests/StewardSubmissionCard.test.js b/frontend/src/tests/StewardSubmissionCard.test.js
index b7e05d45..6bf2e8ec 100644
--- a/frontend/src/tests/StewardSubmissionCard.test.js
+++ b/frontend/src/tests/StewardSubmissionCard.test.js
@@ -196,6 +196,30 @@ describe('StewardSubmissionCard', () => {
expect(screen.getByText('The rejection overlooked the attached evidence.')).toBeTruthy();
});
+ it('pairs a more-information request with its distinct submitter response', () => {
+ renderCard({
+ submission: makeSubmission({
+ more_info_requests: [{
+ id: 31,
+ message: 'Add installation instructions.',
+ user_name: 'Review Steward',
+ created_at: '2026-06-02T12:00:00Z',
+ response: {
+ id: 44,
+ message: 'Added installation and environment setup instructions.',
+ user_name: 'Project Builder',
+ created_at: '2026-06-03T12:00:00Z'
+ }
+ }]
+ })
+ });
+
+ expect(screen.getByText('Add installation instructions.')).toBeTruthy();
+ expect(screen.getByText('Submitter response')).toBeTruthy();
+ expect(screen.getByText('Added installation and environment setup instructions.')).toBeTruthy();
+ expect(screen.getByText('Responded by Project Builder')).toBeTruthy();
+ });
+
it('shows the compact AI proposal context without a competing human proposal', () => {
const aiAnalysis = makeAIAnalysis();
renderCard({
diff --git a/frontend/src/tests/SubmissionCard.test.js b/frontend/src/tests/SubmissionCard.test.js
index 093db477..dcb2a05a 100644
--- a/frontend/src/tests/SubmissionCard.test.js
+++ b/frontend/src/tests/SubmissionCard.test.js
@@ -198,6 +198,105 @@ describe('SubmissionCard', () => {
expect(screen.queryByText('Staff Response')).toBeNull();
});
+ it('renders each submitter response separately from the steward request', () => {
+ render(SubmissionCard, {
+ props: {
+ submission: makeSubmission({
+ state: 'pending',
+ more_info_requests: [{
+ id: 2,
+ message: 'Please add repository setup instructions.',
+ user_name: 'Test Steward',
+ created_at: '2026-06-02T12:00:00Z',
+ response: {
+ id: 4,
+ message: 'Added a setup section to the README.',
+ user_name: 'Project Builder',
+ created_at: '2026-06-03T12:00:00Z'
+ }
+ }]
+ })
+ }
+ });
+
+ expect(screen.getByText('Please add repository setup instructions.')).toBeTruthy();
+ expect(screen.getByText('Your response')).toBeTruthy();
+ expect(screen.getByText('Added a setup section to the README.')).toBeTruthy();
+ });
+
+ it('requires an inline response before directly resubmitting unchanged details', async () => {
+ const onResubmit = vi.fn().mockResolvedValue();
+ render(SubmissionCard, {
+ props: {
+ submission: makeSubmission({
+ state: 'more_info_needed',
+ state_display: 'More Information Needed',
+ more_info_requests: [{
+ id: 23,
+ message: 'Document the latest repository changes.',
+ response: null
+ }]
+ }),
+ onResubmit
+ }
+ });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Resubmit' }));
+ const responseInput = screen.getByRole('textbox', { name: 'What did you change?' });
+ const sendButton = screen.getByRole('button', { name: 'Send response and resubmit' });
+ expect(responseInput.required).toBe(true);
+ expect(responseInput.maxLength).toBe(1000);
+ expect(sendButton.disabled).toBe(true);
+
+ await fireEvent.input(responseInput, {
+ target: { value: ' Updated the repository and release notes. ' }
+ });
+ expect(sendButton.disabled).toBe(false);
+ await fireEvent.click(sendButton);
+
+ await waitFor(() => {
+ expect(onResubmit).toHaveBeenCalledWith(42, {
+ request_id: 23,
+ message: 'Updated the repository and release notes.'
+ });
+ });
+ });
+
+ it('keeps the inline response open and surfaces resubmission errors', async () => {
+ const onResubmit = vi.fn().mockRejectedValue({
+ response: {
+ data: {
+ evidence_items: 'At least one current evidence URL is required.'
+ }
+ }
+ });
+ render(SubmissionCard, {
+ props: {
+ submission: makeSubmission({
+ state: 'more_info_needed',
+ state_display: 'More Information Needed',
+ more_info_requests: [{
+ id: 23,
+ message: 'Add current evidence.',
+ response: null
+ }]
+ }),
+ onResubmit
+ }
+ });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Resubmit' }));
+ await fireEvent.input(screen.getByRole('textbox', { name: 'What did you change?' }), {
+ target: { value: 'Updated the repository evidence.' }
+ });
+ await fireEvent.click(screen.getByRole('button', { name: 'Send response and resubmit' }));
+
+ expect((await screen.findByRole('alert')).textContent).toContain(
+ 'At least one current evidence URL is required.'
+ );
+ expect(screen.getByRole('textbox', { name: 'What did you change?' })).toBeTruthy();
+ });
+
it('shows the final staff response and awarded contribution after acceptance', () => {
render(SubmissionCard, {
props: {
@@ -254,6 +353,25 @@ describe('SubmissionCard', () => {
expect(screen.getByLabelText('Appeal reason').value).toBe('');
});
+ it('keeps Appeal available while routing rejected Resubmit to a new form', async () => {
+ render(SubmissionCard, {
+ props: {
+ submission: makeSubmission({
+ id: 'rejected-42',
+ state: 'rejected',
+ state_display: 'Rejected',
+ staff_reply: 'Correct the evidence and try again.'
+ }),
+ onAppeal: vi.fn()
+ }
+ });
+
+ expect(screen.getByRole('button', { name: 'Submit Appeal' })).toBeTruthy();
+ expect(screen.getByText(/creates a new, editable contribution/)).toBeTruthy();
+ await fireEvent.click(screen.getByRole('button', { name: 'Resubmit' }));
+ expect(push).toHaveBeenCalledWith('/submit-contribution?resubmit=rejected-42');
+ });
+
it('does not offer a second appeal and shows an appeal under review', () => {
const { unmount } = render(SubmissionCard, {
props: {
diff --git a/frontend/src/tests/SubmitContributionResubmit.test.js b/frontend/src/tests/SubmitContributionResubmit.test.js
new file mode 100644
index 00000000..089579aa
--- /dev/null
+++ b/frontend/src/tests/SubmitContributionResubmit.test.js
@@ -0,0 +1,399 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/svelte/svelte5';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import SubmitContribution from '../routes/SubmitContribution.svelte';
+
+const mocks = vi.hoisted(() => ({
+ query: 'resubmit=rejected-42&mission=999&type=998',
+ push: vi.fn(),
+ api: {
+ get: vi.fn(),
+ post: vi.fn(),
+ put: vi.fn()
+ },
+ getAllContributionTypes: vi.fn(),
+ getContributionType: vi.fn(),
+ getMission: vi.fn(),
+ getAcceptedProjects: vi.fn(),
+ getMissions: vi.fn(),
+ trackEvent: vi.fn(),
+ authStore: /** @type {import('svelte/store').Writable | null} */ (null)
+}));
+
+vi.mock('svelte-spa-router', () => ({
+ push: mocks.push,
+ querystring: {
+ subscribe(run) {
+ run(mocks.query);
+ return () => {};
+ }
+ }
+}));
+
+vi.mock('../lib/api.js', () => ({
+ default: mocks.api,
+ contributionsAPI: {
+ getAllContributionTypes: mocks.getAllContributionTypes,
+ getContributionType: mocks.getContributionType,
+ getMission: mocks.getMission
+ },
+ submissionsAPI: {
+ getAcceptedProjects: mocks.getAcceptedProjects
+ }
+}));
+
+vi.mock('../lib/missionsStore.js', () => ({
+ getMissions: mocks.getMissions
+}));
+
+vi.mock('../lib/auth.js', async () => {
+ const { writable } = await import('svelte/store');
+ mocks.authStore = writable({
+ isAuthenticated: true,
+ address: '0xbuilder',
+ loading: false,
+ error: null
+ });
+ return {
+ authState: mocks.authStore
+ };
+});
+
+vi.mock('../lib/userStore.js', async () => {
+ const { readable } = await import('svelte/store');
+ return {
+ userStore: readable({
+ user: {
+ address: '0xbuilder',
+ builder: true,
+ validator: false,
+ creator: false,
+ twitter_connection: null,
+ discord_connection: null,
+ github_connection: null
+ },
+ loading: false,
+ error: null
+ })
+ };
+});
+
+vi.mock('../lib/analytics.js', () => ({
+ getAnalyticsContext: (properties) => properties,
+ getLifecycleDurationMs: () => 0,
+ getLifecycleDurations: () => ({}),
+ markLifecycleTime: () => false,
+ trackEvent: mocks.trackEvent
+}));
+
+const genericEvidenceType = {
+ id: 91,
+ name: 'Other',
+ slug: 'other',
+ is_generic: true,
+ order: 99,
+ url_patterns: []
+};
+
+const builderType = {
+ id: 7,
+ name: 'Builder Project',
+ slug: 'projects',
+ category: 'builder',
+ description: 'Build a project.',
+ is_submittable: true,
+ is_full: false,
+ user_weekly_is_full: false,
+ min_points: 10,
+ max_points: 100,
+ accepted_evidence_url_types: [genericEvidenceType],
+ required_evidence_url_types: [],
+ required_social_accounts: [],
+ required_discord_roles: []
+};
+
+function rejectedSource(overrides = {}) {
+ return {
+ id: 'rejected-42',
+ state: 'rejected',
+ contribution_type: builderType.id,
+ contribution_type_name: builderType.name,
+ contribution_type_details: builderType,
+ contribution_date: '2026-07-18T12:00:00Z',
+ title: 'Original project title',
+ notes: 'Original project notes',
+ staff_reply: 'The evidence did not describe the final release.',
+ mission: null,
+ project_contribution: null,
+ evidence_items: [{
+ id: 81,
+ description: 'Release evidence',
+ url: 'https://example.com/original-release',
+ url_type: genericEvidenceType
+ }],
+ ...overrides
+ };
+}
+
+describe('rejected contribution resubmission', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.query = 'resubmit=rejected-42&mission=999&type=998';
+ mocks.api.get.mockResolvedValue({ data: rejectedSource() });
+ mocks.api.post.mockResolvedValue({ data: { id: 'new-submission-84' } });
+ mocks.getAllContributionTypes.mockResolvedValue({ data: [builderType] });
+ mocks.getContributionType.mockResolvedValue({ data: builderType });
+ mocks.getMission.mockResolvedValue({ data: null });
+ mocks.getAcceptedProjects.mockResolvedValue({ data: [] });
+ mocks.getMissions.mockResolvedValue([]);
+ mocks.authStore?.set({
+ isAuthenticated: true,
+ address: '0xbuilder',
+ loading: false,
+ error: null
+ });
+ sessionStorage.clear();
+ window.grecaptcha = {
+ render: vi.fn(() => 12),
+ getResponse: vi.fn(() => 'recaptcha-token'),
+ reset: vi.fn()
+ };
+ });
+
+ it('clones every supported field without record ids and creates a new row', async () => {
+ render(SubmitContribution);
+
+ expect(await screen.findByRole('heading', { name: 'Resubmit contribution' })).toBeTruthy();
+ expect(screen.getByText('Original rejection reason')).toBeTruthy();
+ const typeInput = await screen.findByDisplayValue('Builder Project');
+ await waitFor(() => expect(typeInput.disabled).toBe(false));
+ expect(screen.getByDisplayValue('Original project title')).toBeTruthy();
+ expect(screen.getByDisplayValue('Original project notes')).toBeTruthy();
+ expect(screen.getByDisplayValue('https://example.com/original-release')).toBeTruthy();
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Resubmit Contribution' }));
+
+ await waitFor(() => expect(mocks.api.post).toHaveBeenCalledTimes(1));
+ expect(mocks.api.put).not.toHaveBeenCalled();
+ const [path, payload] = mocks.api.post.mock.calls[0];
+ expect(path).toBe('/submissions/');
+ expect(payload).toMatchObject({
+ contribution_type: builderType.id,
+ contribution_date: '2026-07-18T00:00:00Z',
+ title: 'Original project title',
+ notes: 'Original project notes',
+ recaptcha: 'recaptcha-token',
+ evidence_items: [{
+ description: 'Release evidence',
+ url: 'https://example.com/original-release'
+ }]
+ });
+ expect(payload.evidence_items[0]).not.toHaveProperty('id');
+ expect(mocks.getMission).not.toHaveBeenCalledWith(999);
+ expect(mocks.push).toHaveBeenCalledWith(
+ '/my-submissions?submission=new-submission-84'
+ );
+ expect(sessionStorage.getItem('submissionUpdateSuccess')).toContain(
+ 'corrected contribution'
+ );
+ });
+
+ it('keeps cloned content but requires a new selection for an expired mission', async () => {
+ const expiredMission = {
+ id: 55,
+ name: 'Expired build mission',
+ contribution_type: builderType.id,
+ is_active: false,
+ end_date: '2026-01-01T00:00:00Z',
+ is_full: false
+ };
+ mocks.api.get.mockResolvedValue({
+ data: rejectedSource({ mission: { id: 55, name: expiredMission.name } })
+ });
+ mocks.getMission.mockResolvedValue({ data: expiredMission });
+
+ render(SubmitContribution);
+
+ expect(await screen.findByRole('heading', { name: 'Resubmit contribution' })).toBeTruthy();
+ expect(await screen.findByText(/is no longer accepting submissions/)).toBeTruthy();
+ expect(screen.getByText(/copied details are intact/)).toBeTruthy();
+ expect(screen.queryByRole('button', { name: 'Resubmit Contribution' })).toBeNull();
+ expect(mocks.getMission).toHaveBeenCalledWith(55);
+ expect(mocks.getMission).not.toHaveBeenCalledWith(999);
+ });
+
+ it('preserves cloned content but does not preselect a retired type', async () => {
+ const retiredType = {
+ ...builderType,
+ name: 'Retired Builder Type',
+ is_submittable: false
+ };
+ const replacementType = {
+ ...builderType,
+ id: 8,
+ name: 'Current Builder Type',
+ slug: 'current-builder-type'
+ };
+ mocks.api.get.mockResolvedValue({
+ data: rejectedSource({
+ contribution_type_name: retiredType.name,
+ contribution_type_details: retiredType
+ })
+ });
+ mocks.getAllContributionTypes.mockResolvedValue({
+ data: [retiredType, replacementType]
+ });
+
+ render(SubmitContribution);
+
+ expect(await screen.findByText(/no longer accepts direct submissions/)).toBeTruthy();
+ expect(screen.queryByRole('button', { name: 'Resubmit Contribution' })).toBeNull();
+ await fireEvent.click(screen.getByText(replacementType.name));
+ expect(screen.getByDisplayValue('Original project title')).toBeTruthy();
+ expect(screen.getByDisplayValue('https://example.com/original-release')).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Resubmit Contribution' })).toBeTruthy();
+ });
+
+ it('preserves cloned content but does not preselect a full type', async () => {
+ const fullType = {
+ ...builderType,
+ name: 'Full Builder Type',
+ is_full: true
+ };
+ const replacementType = {
+ ...builderType,
+ id: 8,
+ name: 'Current Builder Type',
+ slug: 'current-builder-type'
+ };
+ mocks.api.get.mockResolvedValue({
+ data: rejectedSource({
+ contribution_type_name: fullType.name,
+ contribution_type_details: fullType
+ })
+ });
+ mocks.getAllContributionTypes.mockResolvedValue({
+ data: [fullType, replacementType]
+ });
+
+ render(SubmitContribution);
+
+ expect(await screen.findByText(/is currently full/)).toBeTruthy();
+ expect(screen.queryByRole('button', { name: 'Resubmit Contribution' })).toBeNull();
+ await fireEvent.click(screen.getByText(replacementType.name));
+ expect(screen.getByDisplayValue('Original project notes')).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Resubmit Contribution' })).toBeTruthy();
+ });
+
+ it('prefills an active mission and keeps the normal POST path', async () => {
+ const activeMission = {
+ id: 55,
+ name: 'Active build mission',
+ contribution_type: builderType.id,
+ is_active: true,
+ is_full: false,
+ user_is_full: false
+ };
+ mocks.api.get.mockResolvedValue({
+ data: rejectedSource({ mission: { id: 55, name: activeMission.name } })
+ });
+ mocks.getMissions.mockResolvedValue([activeMission]);
+
+ render(SubmitContribution);
+
+ const missionInput = await screen.findByDisplayValue(activeMission.name);
+ await waitFor(() => expect(missionInput.disabled).toBe(false));
+ await fireEvent.click(screen.getByRole('button', { name: 'Resubmit Contribution' }));
+
+ await waitFor(() => expect(mocks.api.post).toHaveBeenCalledTimes(1));
+ expect(mocks.api.post.mock.calls[0][1]).toMatchObject({
+ contribution_type: builderType.id,
+ mission: activeMission.id,
+ recaptcha: 'recaptcha-token'
+ });
+ });
+
+ it('prefills an available linked project for a milestone clone', async () => {
+ const milestoneType = {
+ ...builderType,
+ name: 'Milestones',
+ slug: 'milestones'
+ };
+ const linkedProject = {
+ id: 301,
+ title: 'Original highlighted project',
+ next_milestone_version: 3
+ };
+ mocks.api.get.mockResolvedValue({
+ data: rejectedSource({
+ contribution_type_name: milestoneType.name,
+ contribution_type_details: milestoneType,
+ project_contribution: linkedProject
+ })
+ });
+ mocks.getAllContributionTypes.mockResolvedValue({ data: [milestoneType] });
+ mocks.getAcceptedProjects.mockResolvedValue({ data: [linkedProject] });
+
+ render(SubmitContribution);
+
+ const typeInput = await screen.findByDisplayValue(milestoneType.name);
+ await waitFor(() => expect(typeInput.disabled).toBe(false));
+ await fireEvent.click(screen.getByRole('button', { name: 'Resubmit Contribution' }));
+
+ await waitFor(() => expect(mocks.api.post).toHaveBeenCalledTimes(1));
+ expect(mocks.api.post.mock.calls[0][1]).toMatchObject({
+ contribution_type: milestoneType.id,
+ project_contribution: String(linkedProject.id),
+ recaptcha: 'recaptcha-token'
+ });
+ });
+
+ it('refuses to prefill a source that is no longer rejected', async () => {
+ mocks.api.get.mockResolvedValue({
+ data: rejectedSource({ state: 'pending' })
+ });
+
+ render(SubmitContribution);
+
+ expect(await screen.findByRole('heading', { name: 'Unable to start resubmission' })).toBeTruthy();
+ expect(screen.getByText(/still rejected/)).toBeTruthy();
+ expect(mocks.getAllContributionTypes).not.toHaveBeenCalled();
+ });
+
+ it('surfaces source loading failures and supports retry', async () => {
+ mocks.api.get
+ .mockRejectedValueOnce(new Error('Network unavailable'))
+ .mockResolvedValueOnce({ data: rejectedSource() });
+
+ render(SubmitContribution);
+
+ expect(await screen.findByText(/couldn't load the rejected submission/)).toBeTruthy();
+ await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
+
+ expect(await screen.findByRole('heading', { name: 'Resubmit contribution' })).toBeTruthy();
+ expect(mocks.api.get).toHaveBeenCalledTimes(2);
+ });
+
+ it('loads the owner-scoped source after signing in without remounting', async () => {
+ mocks.authStore?.set({
+ isAuthenticated: false,
+ address: null,
+ loading: false,
+ error: null
+ });
+
+ render(SubmitContribution);
+
+ expect(await screen.findByText('Authentication Required')).toBeTruthy();
+ expect(mocks.api.get).not.toHaveBeenCalled();
+
+ mocks.authStore?.set({
+ isAuthenticated: true,
+ address: '0xbuilder',
+ loading: false,
+ error: null
+ });
+
+ expect(await screen.findByRole('heading', { name: 'Resubmit contribution' })).toBeTruthy();
+ expect(mocks.api.get).toHaveBeenCalledWith('/submissions/rejected-42/');
+ });
+});
diff --git a/frontend/src/tests/submissionErrors.test.js b/frontend/src/tests/submissionErrors.test.js
new file mode 100644
index 00000000..c3699245
--- /dev/null
+++ b/frontend/src/tests/submissionErrors.test.js
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+import { submissionErrorMessage } from '../lib/submissionErrors.js';
+
+describe('submissionErrorMessage', () => {
+ it('reads nested DRF response and evidence errors', () => {
+ expect(submissionErrorMessage({
+ response: {
+ data: {
+ more_info_response: {
+ request_id: ['Refresh and answer the latest request.']
+ }
+ }
+ }
+ }, 'Fallback')).toBe('Refresh and answer the latest request.');
+
+ expect(submissionErrorMessage({
+ response: {
+ data: {
+ evidence_items: [{ url: ['Use an accepted evidence URL.'] }]
+ }
+ }
+ }, 'Fallback')).toBe('Use an accepted evidence URL.');
+ });
+
+ it('preserves plain errors and falls back for empty response objects', () => {
+ expect(submissionErrorMessage(
+ new Error('Submission is no longer visible.'),
+ 'Fallback'
+ )).toBe('Submission is no longer visible.');
+
+ expect(submissionErrorMessage({
+ response: {
+ data: {
+ more_info_response: {},
+ evidence_items: []
+ }
+ }
+ }, 'Fallback')).toBe('Fallback');
+ });
+});