Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,17 @@ setup.sh

# gstack security reports
.gstack/

# Playwright artifacts
playwright-report/
test-results/
.playwright/
**/playwright-report/
**/test-results/
**/.playwright/
**/playwright/.cache/
**/playwright/.auth/
playwright*.yml
playwright*.yaml
**/playwright/*.yml
**/playwright/*.yaml
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable user-facing changes to this project will be documented in this file.

## Unreleased

- Builders, validators, and community members now have a dedicated onboarding funnel. Each role's landing page adapts to whether you are signed in and shows a step-by-step journey to unlock that role: builders connect GitHub and star the boilerplate repo, community members link X and Discord, follow and join, and share a verified post on X, and validators complete the waitlist form. Completing a journey grants the role itself (points come only from verifiable tasks), sections stay locked until the relevant journey is finished, and your profile shows in-progress roles privately until earned (66677ffe)
- The portal now uses clean URLs (e.g. `/hackathon` instead of `/#/hackathon`), so links you copy and share show the correct page preview on Discord, Slack, X, and other platforms; previously every shared link previewed as the generic homepage. Already-shared `/#/` links still work (84dbe5d4)
- Staff can now send custom notifications from Django admin targeted at everyone, at users with selected roles (builders, validators, stewards, creators), at hand-picked users, or at a pasted batch of wallet addresses (unmatched lines are reported back). Announcements can be pure text with no link, bodies support markdown on the notifications page, drafts preview their reach before sending, and a deliberate resend resurfaces the notification as unread instead of duplicating. These campaigns are private to their recipients and the recipient-resolution step is built to be reused by future email and Telegram campaigns (4cf30bbc)
- Signed-in users now get in-portal notifications: a navbar bell with unread badge and dropdown plus a full notifications page. Submission review decisions (accepted, rejected, more info needed), highlighted contributions, referrals joining, and validator graduation notify automatically; admins can explicitly broadcast featured content, alerts, partners, new contribution types, missions, Gen TV streams, POAP drops, validator-targeted node upgrade announcements, and new social tasks (sent only to the role the task's category targets: builders, validators, or community members) from Django admin (silent by default). Review notifications deep-link to the matching row in My Submissions (7bf1899c)
Expand Down
3 changes: 3 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,9 @@ GET /api/v1/users/by-address/{address}/ (requires auth)
GET /api/v1/users/validators/ (requires auth)
POST /api/v1/users/link_x_account/ (requires auth, awards configured points for linking X)
POST /api/v1/users/link_discord_account/ (requires auth, awards configured points for linking Discord)
POST /api/v1/users/link_github_account/ (requires auth, awards community-link-github points; auto-fired by SocialLink.svelte on GitHub link)
POST /api/v1/users/start_builder_journey/ (requires auth, no-op: no longer awards points)
POST /api/v1/users/complete_builder_journey/ (requires auth, grants Builder role point-free; gated on the star-boilerplate social task)

# Social Tasks
GET /api/v1/social-tasks/ (?status=active|completed&category=community|builder|validator)
Expand Down
28 changes: 1 addition & 27 deletions backend/community_xp/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from django.utils import timezone
from requests import RequestException

from creators.models import Creator
from social_connections.models import DiscordConnection
from tally.middleware.logging_utils import get_app_logger

Expand Down Expand Up @@ -310,22 +309,6 @@ def _connection_map(discord_ids):
return by_discord_id


def _auto_create_creator_profiles(user_ids):
user_ids = {user_id for user_id in user_ids if user_id}
if not user_ids:
return 0

existing = set(
Creator.objects
.filter(user_id__in=user_ids)
.values_list('user_id', flat=True)
)
missing = [Creator(user_id=user_id) for user_id in user_ids - existing]
if missing:
Creator.objects.bulk_create(missing, ignore_conflicts=True)
return len(missing)


def store_fetch_result(run, fetch_result):
now = timezone.now()
discord_ids = [player.discord_id for player in fetch_result.players]
Expand Down Expand Up @@ -388,7 +371,6 @@ def store_fetch_result(run, fetch_result):
'unmatched_players': run.unmatched_players,
'duplicate_players': run.duplicate_players,
'applied': False,
'creator_profiles_created': 0,
}


Expand Down Expand Up @@ -451,7 +433,6 @@ def apply_sync_run(run, applied_by=None):
discord_ids = [snapshot.discord_id for snapshot in snapshots]
connections_by_discord_id = _connection_map(discord_ids)
matched_user_ids = set()
creator_user_ids = set()
current_rows = []

for snapshot in snapshots:
Expand All @@ -460,8 +441,6 @@ def apply_sync_run(run, applied_by=None):
matched_at = now if matched_user else None
if matched_user:
matched_user_ids.add(matched_user.id)
if snapshot.xp > 0:
creator_user_ids.add(matched_user.id)

current_rows.append(Mee6CurrentXP(
guild_id=snapshot.guild_id,
Expand All @@ -484,7 +463,6 @@ def apply_sync_run(run, applied_by=None):
with transaction.atomic():
Mee6CurrentXP.objects.filter(guild_id=run.guild_id).delete()
Mee6CurrentXP.objects.bulk_create(current_rows, batch_size=1000)
creator_profiles_created = _auto_create_creator_profiles(creator_user_ids)

run.matched_players = len(matched_user_ids)
run.unmatched_players = len(snapshots) - len(matched_user_ids)
Expand All @@ -505,7 +483,6 @@ def apply_sync_run(run, applied_by=None):
'players_applied': len(snapshots),
'matched_players': run.matched_players,
'unmatched_players': run.unmatched_players,
'creator_profiles_created': creator_profiles_created,
'applied_at': run.applied_at,
}

Expand Down Expand Up @@ -549,7 +526,7 @@ def run_mee6_sync(guild_id=None, page_size=None, client=None, use_lock=True):
release_sync_lock(owner_token)


def match_current_xp_for_connection(connection, guild_id=None, create_creator=True):
def match_current_xp_for_connection(connection, guild_id=None):
guild_id = str(guild_id or get_default_guild_id())
discord_id = str(connection.platform_user_id or '')
if not discord_id:
Expand All @@ -571,9 +548,6 @@ def match_current_xp_for_connection(connection, guild_id=None, create_creator=Tr
current.matched_at = timezone.now()
current.save(update_fields=['matched_user', 'matched_at', 'updated_at'])

if create_creator and current.xp > 0:
Creator.objects.get_or_create(user=connection.user)

return current


Expand Down
23 changes: 13 additions & 10 deletions backend/community_xp/tests/test_mee6_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ def setUp(self):
max_points=1_000_000,
is_submittable=True,
)
self.discord_link_type = ContributionType.objects.create(
name='Link Discord Account',
self.discord_link_type, _ = ContributionType.objects.update_or_create(
slug='community-link-discord',
category=self.community_category,
min_points=20,
max_points=20,
is_submittable=False,
defaults={
'name': 'Link Discord Account',
'category': self.community_category,
'min_points': 20,
'max_points': 20,
'is_submittable': False,
},
)
GlobalLeaderboardMultiplier.objects.create(
contribution_type=self.community_type,
Expand Down Expand Up @@ -326,7 +328,7 @@ def test_sync_preserves_contributions_and_counts_mee6_plus_pending_portal_points
self.assertEqual(breakdown['pending_portal_points'], 50)
self.assertEqual(breakdown['tracked_portal_points_all_time'], 50)
self.assertEqual(breakdown['total_points'], 150)
self.assertTrue(Creator.objects.filter(user=self.user).exists())
self.assertFalse(Creator.objects.filter(user=self.user).exists())

def test_post_baseline_distributed_points_count_until_next_mee6_baseline(self):
self.link_discord(self.user)
Expand Down Expand Up @@ -360,6 +362,7 @@ def test_post_baseline_distributed_points_count_until_next_mee6_baseline(self):
self.assertEqual(Contribution.objects.count(), 2)

def test_unmatched_snapshot_is_stored_without_creating_user(self):
user_count = User.objects.count()
run = self.fetch_mee6_run([mee6_player('unmatched-discord', 77)])

snapshot = Mee6PlayerSnapshot.objects.get(discord_id='unmatched-discord')
Expand All @@ -370,9 +373,9 @@ def test_unmatched_snapshot_is_stored_without_creating_user(self):
current = Mee6CurrentXP.objects.get(discord_id='unmatched-discord')

self.assertIsNone(current.matched_user)
self.assertEqual(User.objects.count(), 1)
self.assertEqual(User.objects.count(), user_count)

def test_late_discord_link_matches_current_xp_and_creates_creator_profile(self):
def test_late_discord_link_matches_current_xp_without_creating_creator_profile(self):
run = self.fetch_mee6_run([mee6_player('late-discord', 77)])
late_user = User.objects.create_user(
email='late@example.com',
Expand All @@ -389,7 +392,7 @@ def test_late_discord_link_matches_current_xp_and_creates_creator_profile(self):
breakdown = get_effective_community_points(late_user)

self.assertEqual(current.matched_user, late_user)
self.assertTrue(Creator.objects.filter(user=late_user).exists())
self.assertFalse(Creator.objects.filter(user=late_user).exists())
self.assertEqual(breakdown['discord_xp'], 77)
self.assertEqual(breakdown['total_points'], 77)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""GitHub-link reward (builder category) + builder-welcome as a 0-point marker.

Merged from the former 0073 + 0074 into one migration.

- Linking GitHub is a BUILDER action, so the reward type lives in the `builder`
category (it feeds the builder leaderboard / shows as a builder contribution).
The slug stays `community-link-github` and the serializer field stays
`has_community_link_github` to match the existing link-reward family and the
frontend that already consumes that field.
- `builder-welcome` is no longer a farmable +20 reward; it is the point-free
"started the builder journey" marker, so its type is relaxed to 0 points.
"""

from django.db import migrations


def apply(apps, schema_editor):
Category = apps.get_model('contributions', 'Category')
ContributionType = apps.get_model('contributions', 'ContributionType')

builder_category = Category.objects.filter(slug='builder').first()
if builder_category is None:
raise RuntimeError('builder category missing; cannot seed community-link-github')
ContributionType.objects.update_or_create(
slug='community-link-github',
defaults={
'name': 'Link GitHub Account',
'description': 'Linked your GitHub account to your GenLayer profile',
'category': builder_category,
'min_points': 25,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'max_points': 25,
'is_default': False,
'is_submittable': False,
},
)

ContributionType.objects.filter(slug='builder-welcome').update(min_points=0, max_points=0)


def reverse(apps, schema_editor):
ContributionType = apps.get_model('contributions', 'ContributionType')
ContributionType.objects.filter(slug='community-link-github').delete()
ContributionType.objects.filter(slug='builder-welcome').update(min_points=20, max_points=20)


class Migration(migrations.Migration):

dependencies = [
('contributions', '0072_submittedcontribution_gate_reviewed'),
]

operations = [
migrations.RunPython(apply, reverse),
]
7 changes: 5 additions & 2 deletions backend/contributions/tests/test_is_submittable.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,11 @@ def setUp(self):

def test_steward_can_see_all_types_without_filter(self):
"""Test that when no is_submittable filter is provided, all types are returned."""
# This simulates steward mode where they don't send the is_submittable filter
response = self.client.get(self.api_url)
# This simulates steward mode where they don't send the is_submittable filter.
# Request a large page so all test types stay on one page (the default
# page size is 10; new system contribution types must not push them onto
# page 2 and break this count).
response = self.client.get(self.api_url, {'page_size': 100})

self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
Expand Down
132 changes: 132 additions & 0 deletions backend/creators/community_journey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Community journey logic: the 5 steps to become a Creator (community member).

1. Link X -> `community-link-x` contribution (existing reward)
2. Link Discord -> `community-link-discord` contribution (existing reward)
3. Follow GenLayer -> `follow-genlayer-x` social task completion
4. Join Discord -> `join-genlayer-discord` social task completion
5. X post with code -> a CommunityPostProof, verified via Sorsa /tweet-info

Completing all 5 grants the Creator role (point-free); steps 1-4 keep their own
existing points. The X-post step uses a deterministic per-user code (no storage
of the code needed) and the post must @mention GenLayer and come from the user's
linked X account.
"""

import hashlib
import hmac
import re
from urllib.parse import quote

from django.conf import settings

LINK_X_SLUG = 'community-link-x'
LINK_DISCORD_SLUG = 'community-link-discord'
FOLLOW_TASK_SLUG = 'follow-genlayer-x'
JOIN_DISCORD_TASK_SLUG = 'join-genlayer-discord'
WELCOME_SLUG = 'community-welcome'

CODE_PREFIX = 'GL-'

# A well-formed X / Twitter post URL: https://x.com/<handle>/status/<id>
X_POST_RE = re.compile(
r'^https?://(?:www\.)?(?:x\.com|twitter\.com)/'
r'(?P<handle>[A-Za-z0-9_]{1,15})/status(?:es)?/(?P<id>\d+)',
re.IGNORECASE,
)


def genlayer_handle() -> str:
return getattr(settings, 'GENLAYER_X_HANDLE', 'genlayer').lstrip('@').lower()


def verification_code(user) -> str:
"""Deterministic per-user code embedded in the X post. Recomputed at verify
time, so nothing is stored. Tied to SECRET_KEY so it cannot be guessed."""
digest = hmac.new(
settings.SECRET_KEY.encode(),
f'community-x-post:{user.pk}'.encode(),
hashlib.sha256,
).hexdigest()[:8].upper()
return f'{CODE_PREFIX}{digest}'


def share_text(user) -> str:
return f"I'm joining the @{genlayer_handle()} community! {verification_code(user)}"


def intent_url(user) -> str:
return f'https://x.com/intent/post?text={quote(share_text(user))}'


def parse_x_post(url: str):
"""Return (handle_lower, tweet_id) for a well-formed X post URL, else (None, None)."""
match = X_POST_RE.match((url or '').strip())
if not match:
return None, None
return match.group('handle').lower(), match.group('id')


def post_matches(full_text: str, user):
"""Whether the tweet text contains the user's code and @mentions GenLayer.
Returns (ok, error_code)."""
text = (full_text or '').lower()
if verification_code(user).lower() not in text:
return False, 'code_missing'
handle_re = re.compile(rf'(^|[^a-z0-9_])@{re.escape(genlayer_handle())}(?![a-z0-9_])')
if not handle_re.search(text):
return False, 'tag_missing'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return True, None


def _has_contribution(user, slug) -> bool:
from contributions.models import Contribution
return Contribution.objects.filter(user=user, contribution_type__slug=slug).exists()


def _has_task_completion(user, slug) -> bool:
from social_tasks.models import SocialTaskCompletion
return SocialTaskCompletion.objects.filter(user=user, task__slug=slug).exists()


def is_started(user) -> bool:
return _has_contribution(user, WELCOME_SLUG)


def step_states(user) -> dict:
return {
'link_x': _has_contribution(user, LINK_X_SLUG),
'link_discord': _has_contribution(user, LINK_DISCORD_SLUG),
'follow_x': _has_task_completion(user, FOLLOW_TASK_SLUG),
'join_discord': _has_task_completion(user, JOIN_DISCORD_TASK_SLUG),
'x_post': hasattr(user, 'community_post_proof'),
}


def journey_status(user) -> dict:
# Existing community members (the Creator role) are grandfathered in: the
# journey only applies to newcomers, so a member is always treated as
# started/complete regardless of the newer welcome-marker + step records.
is_creator = hasattr(user, 'creator')
states = step_states(user)
started = is_creator or is_started(user)
missing_steps = [key for key, done in states.items() if not done]
proof = getattr(user, 'community_post_proof', None)
return {
'started': started,
'steps': {
'link_x': {'done': states['link_x']},
'link_discord': {'done': states['link_discord']},
'follow_x': {'done': states['follow_x']},
'join_discord': {'done': states['join_discord']},
'x_post': {
'done': states['x_post'],
'verification_code': verification_code(user),
'share_text': share_text(user),
'intent_url': intent_url(user),
'post_url': proof.post_url if proof else None,
},
},
'missing_steps': missing_steps,
'complete': is_creator or (started and not missing_steps),
'is_member': is_creator,
}
Comment on lines +95 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don't mark step 5 complete from proof existence alone.

x_post flips to done as soon as community_post_proof exists. If someone verifies with one linked X account and then relinks to another, journey_status() still reports completion, and the legacy join route in backend/creators/views.py:13-58 will grant Creator without re-checking that the stored proof still belongs to the currently linked account. Compare the current linked handle against the proof URL/verified author before returning x_post: true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/creators/community_journey.py` around lines 94 - 127, The x_post step
in step_states and journey_status is currently marked done whenever
community_post_proof exists, which can leave Creator completion stale after an X
relink. Update the x_post check to validate that the stored proof still matches
the currently linked X account before returning done, using the current linked
handle together with the proof URL/verified author. Keep the completion logic in
journey_status consistent so the legacy join route in views does not treat an
outdated proof as valid.

Loading
Loading