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

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 

10 

11from sqlalchemy import desc, func 

12from sqlalchemy.orm import Session 

13 

14from .models import ( 

15 User, Referral, ReferralCode, Boost, Post, ResonanceWallet, 

16) 

17from .resonance_engine import ResonanceService 

18 

19logger = logging.getLogger('hevolve_social') 

20 

21 

22def _generate_code(length: int = 8) -> str: 

23 chars = string.ascii_uppercase + string.digits 

24 return ''.join(secrets.choice(chars) for _ in range(length)) 

25 

26 

27def invite_share_url(code: str, base_url: Optional[str] = None) -> str: 

28 """Build the canonical shareable invite URL for a referral code. 

29 

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. 

37 

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}" 

50 

51 

52class DistributionService: 

53 

54 # ─── Referrals ─── 

55 

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() 

64 

65 code = _generate_code() 

66 # Ensure unique 

67 while db.query(ReferralCode).filter_by(code=code).first(): 

68 code = _generate_code() 

69 

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() 

78 

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 

83 

84 return code_obj.to_dict() 

85 

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 

97 

98 # Check not already referred 

99 existing = db.query(Referral).filter_by(referred_id=referred_user_id).first() 

100 if existing: 

101 return None 

102 

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 

111 

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 

116 

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)) 

120 

121 db.flush() 

122 return referral.to_dict() 

123 

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 

133 

134 user = db.query(User).filter_by(id=referred_user_id).first() 

135 if not user: 

136 return None 

137 

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 

141 

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} 

148 

149 return {'activated': False, 'days_old': days_old, 'has_content': has_content, 'has_upvotes': has_upvotes} 

150 

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 

159 

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 

162 

163 return { 

164 'code': code, 

165 'total_referrals': total, 

166 'activated': activated, 

167 'pending': pending, 

168 } 

169 

170 # ─── Boosts ─── 

171 

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} 

184 

185 multiplier = min(1.0 + spark_amount * 0.01, 2.0) 

186 expires_at = datetime.utcnow() + timedelta(hours=spark_amount) 

187 

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) 

197 

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 

203 

204 db.flush() 

205 return True, boost.to_dict() 

206 

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]