Coverage for integrations / social / distribution_service.py: 24.8%
101 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial - Distribution Service
3Referral codes, boost system, federation contribution, onboarding.
4"""
5import logging
6import secrets
7import string
8from datetime import datetime, timedelta
9from typing import Optional, Dict, List, Tuple
11from sqlalchemy import desc, func
12from sqlalchemy.orm import Session
14from .models import (
15 User, Referral, ReferralCode, Boost, Post, ResonanceWallet,
16)
17from .resonance_engine import ResonanceService
19logger = logging.getLogger('hevolve_social')
22def _generate_code(length: int = 8) -> str:
23 chars = string.ascii_uppercase + string.digits
24 return ''.join(secrets.choice(chars) for _ in range(length))
27def invite_share_url(code: str, base_url: Optional[str] = None) -> str:
28 """Build the canonical shareable invite URL for a referral code.
30 Single source of truth — replaces the hardcoded
31 ``f"https://hevolve.ai/join?ref={ref_code}"`` previously inlined at
32 ``marketing_tools.py:create_referral_campaign`` and any new G1
33 invite-friend tool. Reads ``HEVOLVE_INVITE_BASE_URL`` env var first
34 (so dev / staging / on-prem deployments can override) and defaults
35 to the canonical ``https://hevolve.ai/join`` per the marketing
36 canon.
38 Returns a URL of the form ``<base>?ref=<CODE>``. The path is
39 intentionally identical to the legacy hardcoded one so existing
40 inbound traffic that lands on ``hevolve.ai/join?ref=…`` continues
41 to work unchanged.
42 """
43 import os
44 if not code:
45 raise ValueError("invite_share_url requires a non-empty code")
46 base = base_url or os.environ.get(
47 'HEVOLVE_INVITE_BASE_URL', 'https://hevolve.ai/join')
48 sep = '&' if '?' in base else '?'
49 return f"{base}{sep}ref={code}"
52class DistributionService:
54 # ─── Referrals ───
56 @staticmethod
57 def get_or_create_referral_code(db: Session, user_id: str) -> Dict:
58 """Get or create a referral code for a user."""
59 code_obj = db.query(ReferralCode).filter_by(
60 user_id=user_id, is_active=True
61 ).first()
62 if code_obj:
63 return code_obj.to_dict()
65 code = _generate_code()
66 # Ensure unique
67 while db.query(ReferralCode).filter_by(code=code).first():
68 code = _generate_code()
70 code_obj = ReferralCode(
71 user_id=user_id,
72 code=code,
73 is_active=True,
74 max_uses=100,
75 )
76 db.add(code_obj)
77 db.flush()
79 # Also set on user
80 user = db.query(User).filter_by(id=user_id).first()
81 if user and not user.referral_code:
82 user.referral_code = code
84 return code_obj.to_dict()
86 @staticmethod
87 def use_referral_code(db: Session, referred_user_id: str,
88 code: str) -> Optional[Dict]:
89 """Use a referral code during registration."""
90 code_obj = db.query(ReferralCode).filter_by(code=code, is_active=True).first()
91 if not code_obj:
92 return None
93 if code_obj.user_id == referred_user_id:
94 return None # Can't refer yourself
95 if code_obj.uses >= code_obj.max_uses:
96 return None
98 # Check not already referred
99 existing = db.query(Referral).filter_by(referred_id=referred_user_id).first()
100 if existing:
101 return None
103 referral = Referral(
104 referrer_id=code_obj.user_id,
105 referred_id=referred_user_id,
106 referral_code=code,
107 status='pending',
108 )
109 db.add(referral)
110 code_obj.uses += 1
112 # Set referred_by on user
113 user = db.query(User).filter_by(id=referred_user_id).first()
114 if user:
115 user.referred_by_id = code_obj.user_id
117 # Award referrer for the signup (activation reward comes later)
118 ResonanceService.award_action(db, code_obj.user_id,
119 'referral_signup', str(referral.id))
121 db.flush()
122 return referral.to_dict()
124 @staticmethod
125 def check_referral_activation(db: Session, referred_user_id: str) -> Optional[Dict]:
126 """Check if a referred user qualifies for activation.
127 Criteria: 3+ days old, 1+ post or 3+ comments, 5+ upvotes received."""
128 referral = db.query(Referral).filter_by(
129 referred_id=referred_user_id, status='pending'
130 ).first()
131 if not referral:
132 return None
134 user = db.query(User).filter_by(id=referred_user_id).first()
135 if not user:
136 return None
138 days_old = (datetime.utcnow() - (user.created_at or datetime.utcnow())).days
139 has_content = (user.post_count or 0) >= 1 or (user.comment_count or 0) >= 3
140 has_upvotes = (user.karma_score or 0) >= 5
142 if days_old >= 3 and has_content and has_upvotes:
143 referral.status = 'activated'
144 # Award referrer
145 ResonanceService.award_action(db, referral.referrer_id,
146 'referral_activated', referral.id)
147 return {'activated': True, 'referrer_id': referral.referrer_id}
149 return {'activated': False, 'days_old': days_old, 'has_content': has_content, 'has_upvotes': has_upvotes}
151 @staticmethod
152 def get_referral_stats(db: Session, user_id: str) -> Dict:
153 """Get referral statistics for a user."""
154 total = db.query(func.count(Referral.id)).filter_by(referrer_id=user_id).scalar() or 0
155 activated = db.query(func.count(Referral.id)).filter_by(
156 referrer_id=user_id, status='activated'
157 ).scalar() or 0
158 pending = total - activated
160 code_obj = db.query(ReferralCode).filter_by(user_id=user_id, is_active=True).first()
161 code = code_obj.code if code_obj else None
163 return {
164 'code': code,
165 'total_referrals': total,
166 'activated': activated,
167 'pending': pending,
168 }
170 # ─── Boosts ───
172 @staticmethod
173 def create_boost(db: Session, user_id: str, target_type: str,
174 target_id: str, spark_amount: int) -> Tuple[bool, Dict]:
175 """Create a boost by spending Spark.
176 multiplier = min(1.0 + spark*0.01, 2.0)
177 duration = spark hours"""
178 success, remaining = ResonanceService.spend_spark(
179 db, user_id, spark_amount, 'boost', target_id,
180 f'Boost {target_type} {target_id}'
181 )
182 if not success:
183 return False, {'error': 'Insufficient Spark', 'spark_balance': remaining}
185 multiplier = min(1.0 + spark_amount * 0.01, 2.0)
186 expires_at = datetime.utcnow() + timedelta(hours=spark_amount)
188 boost = Boost(
189 user_id=user_id,
190 target_type=target_type,
191 target_id=target_id,
192 spark_spent=spark_amount,
193 boost_multiplier=multiplier,
194 expires_at=expires_at,
195 )
196 db.add(boost)
198 # If boosting a post, update its boost_score
199 if target_type == 'post':
200 post = db.query(Post).filter_by(id=target_id).first()
201 if post:
202 post.boost_score = (post.boost_score or 0) + multiplier
204 db.flush()
205 return True, boost.to_dict()
207 @staticmethod
208 def get_active_boosts(db: Session, target_type: str,
209 target_id: str) -> List[Dict]:
210 """Get active boosts for a target."""
211 now = datetime.utcnow()
212 boosts = db.query(Boost).filter(
213 Boost.target_type == target_type,
214 Boost.target_id == target_id,
215 Boost.expires_at > now,
216 ).all()
217 return [b.to_dict() for b in boosts]