Coverage for integrations / social / api_gamification.py: 25.6%

1278 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2HevolveSocial - Gamification API Blueprint 

3~76 endpoints for Resonance, Achievements, Challenges, Seasons, Regions, 

4Encounters, Agent Evolution, Ratings, Distribution, Onboarding, Campaigns. 

5""" 

6import logging 

7from datetime import datetime, timedelta 

8from flask import Blueprint, request, jsonify, g 

9 

10from sqlalchemy.orm import Session as SASession 

11from .auth import require_auth, optional_auth, require_admin 

12from .rate_limiter import rate_limit 

13from .models import ( 

14 get_db, User, Post, ResonanceWallet, ResonanceTransaction, 

15 Achievement, UserAchievement, Season, Challenge, UserChallenge, 

16 Region, RegionMembership, Encounter, Rating, TrustScore, 

17 AgentEvolution, AgentCollaboration, Referral, ReferralCode, 

18 Boost, OnboardingProgress, Campaign, CampaignAction, 

19 AdUnit, AdPlacement, AdImpression, PeerNode, HostingReward, 

20) 

21from .resonance_engine import ResonanceService 

22 

23logger = logging.getLogger('hevolve_social') 

24 

25gamification_bp = Blueprint('gamification', __name__, url_prefix='/api/social') 

26 

27 

28def _ok(data=None, meta=None, status=200): 

29 r = {'success': True} 

30 if data is not None: 

31 r['data'] = data 

32 if meta is not None: 

33 r['meta'] = meta 

34 return jsonify(r), status 

35 

36 

37def _err(msg, status=400): 

38 return jsonify({'success': False, 'error': msg}), status 

39 

40 

41def _paginate(total, limit, offset): 

42 return {'total': total, 'limit': limit, 'offset': offset, 

43 'has_more': offset + limit < total} 

44 

45 

46def _get_json(): 

47 return request.get_json(force=True, silent=True) or {} 

48 

49 

50# ═══════════════════════════════════════════════════════════════ 

51# RESONANCE (10 endpoints) 

52# ═══════════════════════════════════════════════════════════════ 

53 

54@gamification_bp.route('/resonance/wallet', methods=['GET']) 

55@require_auth 

56def resonance_wallet_self(): 

57 db = get_db() 

58 try: 

59 wallet = ResonanceService.get_wallet(db, g.user_id) 

60 if not wallet: 

61 wallet = ResonanceService.get_or_create_wallet(db, g.user_id).to_dict() 

62 db.commit() 

63 return _ok(wallet) 

64 finally: 

65 db.close() 

66 

67 

68@gamification_bp.route('/resonance/wallet/<user_id>', methods=['GET']) 

69@optional_auth 

70def resonance_wallet_user(user_id): 

71 db = get_db() 

72 try: 

73 wallet = ResonanceService.get_wallet(db, user_id) 

74 if not wallet: 

75 return _err('Wallet not found', 404) 

76 return _ok(wallet) 

77 finally: 

78 db.close() 

79 

80 

81@gamification_bp.route('/resonance/transactions', methods=['GET']) 

82@require_auth 

83def resonance_transactions(): 

84 db = get_db() 

85 try: 

86 currency = request.args.get('currency') 

87 limit = min(int(request.args.get('limit', 50)), 100) 

88 offset = int(request.args.get('offset', 0)) 

89 txns = ResonanceService.get_transactions(db, g.user_id, currency, limit, offset) 

90 return _ok(txns) 

91 finally: 

92 db.close() 

93 

94 

95@gamification_bp.route('/resonance/leaderboard', methods=['GET']) 

96@optional_auth 

97def resonance_leaderboard(): 

98 db = get_db() 

99 try: 

100 currency = request.args.get('currency', 'pulse') 

101 region_id = request.args.get('region') 

102 limit = min(int(request.args.get('limit', 50)), 100) 

103 offset = int(request.args.get('offset', 0)) 

104 board = ResonanceService.get_leaderboard(db, currency, limit, offset, region_id) 

105 return _ok(board) 

106 finally: 

107 db.close() 

108 

109 

110@gamification_bp.route('/resonance/boost', methods=['POST']) 

111@require_auth 

112def resonance_boost(): 

113 db = get_db() 

114 try: 

115 data = _get_json() 

116 target_type = data.get('target_type') 

117 target_id = data.get('target_id') 

118 spark_amount = int(data.get('spark_amount', 10)) 

119 if not target_type or not target_id: 

120 return _err('target_type and target_id required') 

121 if spark_amount < 1: 

122 return _err('spark_amount must be positive') 

123 

124 ok, remaining = ResonanceService.spend_spark( 

125 db, g.user_id, spark_amount, 'boost', target_id, 

126 f'Boost {target_type} {target_id}') 

127 if not ok: 

128 return _err(f'Insufficient Spark (have {remaining})') 

129 

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

131 hours = spark_amount 

132 boost = Boost( 

133 user_id=g.user_id, target_type=target_type, target_id=target_id, 

134 spark_spent=spark_amount, boost_multiplier=multiplier, 

135 expires_at=datetime.utcnow() + timedelta(hours=hours), 

136 ) 

137 db.add(boost) 

138 

139 if target_type == 'post': 

140 post = db.query(Post).filter_by(id=target_id).first() 

141 if post: 

142 post.boost_score = (post.boost_score or 0) + multiplier 

143 

144 db.commit() 

145 return _ok(boost.to_dict()) 

146 except Exception as e: 

147 db.rollback() 

148 return _err(str(e)) 

149 finally: 

150 db.close() 

151 

152 

153@gamification_bp.route('/resonance/boosts/<target_type>/<target_id>', methods=['GET']) 

154@optional_auth 

155def resonance_boosts_for_target(target_type, target_id): 

156 db = get_db() 

157 try: 

158 boosts = db.query(Boost).filter_by( 

159 target_type=target_type, target_id=target_id 

160 ).filter(Boost.expires_at > datetime.utcnow()).all() 

161 return _ok([b.to_dict() for b in boosts]) 

162 finally: 

163 db.close() 

164 

165 

166@gamification_bp.route('/resonance/level-info', methods=['GET']) 

167@require_auth 

168def resonance_level_info(): 

169 db = get_db() 

170 try: 

171 wallet = ResonanceService.get_or_create_wallet(db, g.user_id) 

172 from .resonance_engine import xp_for_level, title_for_level, LEVEL_TITLES 

173 info = { 

174 'level': wallet.level, 'title': wallet.level_title, 

175 'xp': wallet.xp, 'xp_next': wallet.xp_next_level, 

176 'progress_pct': round(wallet.xp / max(wallet.xp_next_level, 1) * 100, 1), 

177 'all_titles': {str(k): v for k, v in LEVEL_TITLES.items()}, 

178 } 

179 db.commit() 

180 return _ok(info) 

181 finally: 

182 db.close() 

183 

184 

185@gamification_bp.route('/resonance/streak', methods=['GET']) 

186@require_auth 

187def resonance_streak(): 

188 db = get_db() 

189 try: 

190 wallet = ResonanceService.get_or_create_wallet(db, g.user_id) 

191 db.commit() 

192 return _ok({ 

193 'streak_days': wallet.streak_days, 

194 'streak_best': wallet.streak_best, 

195 'last_active_date': wallet.last_active_date, 

196 }) 

197 finally: 

198 db.close() 

199 

200 

201@gamification_bp.route('/resonance/daily-checkin', methods=['POST']) 

202@require_auth 

203def resonance_daily_checkin(): 

204 db = get_db() 

205 try: 

206 result = ResonanceService.process_streak(db, g.user_id) 

207 db.commit() 

208 return _ok(result) 

209 except Exception as e: 

210 db.rollback() 

211 return _err(str(e)) 

212 finally: 

213 db.close() 

214 

215 

216@gamification_bp.route('/resonance/breakdown/<user_id>', methods=['GET']) 

217@optional_auth 

218def resonance_breakdown(user_id): 

219 db = get_db() 

220 try: 

221 breakdown = ResonanceService.get_breakdown(db, user_id) 

222 if not breakdown: 

223 return _err('User not found', 404) 

224 return _ok(breakdown) 

225 finally: 

226 db.close() 

227 

228 

229# ═══════════════════════════════════════════════════════════════ 

230# ACHIEVEMENTS (5 endpoints) 

231# ═══════════════════════════════════════════════════════════════ 

232 

233@gamification_bp.route('/achievements', methods=['GET']) 

234@optional_auth 

235def list_achievements(): 

236 from .gamification_service import GamificationService 

237 db = get_db() 

238 try: 

239 achievements = GamificationService.get_all_achievements(db) 

240 return _ok(achievements) 

241 finally: 

242 db.close() 

243 

244 

245@gamification_bp.route('/achievements/<user_id>', methods=['GET']) 

246@optional_auth 

247def user_achievements(user_id): 

248 from .gamification_service import GamificationService 

249 db = get_db() 

250 try: 

251 achievements = GamificationService.get_user_achievements(db, user_id) 

252 return _ok(achievements) 

253 finally: 

254 db.close() 

255 

256 

257@gamification_bp.route('/achievements/<achievement_id>/showcase', methods=['POST']) 

258@require_auth 

259def toggle_showcase(achievement_id): 

260 from .gamification_service import GamificationService 

261 db = get_db() 

262 try: 

263 data = _get_json() 

264 result = GamificationService.toggle_showcase(db, g.user_id, achievement_id) 

265 if result is None: 

266 return _err("Achievement not found or not unlocked", 404) 

267 db.commit() 

268 return _ok({'is_showcased': result}) 

269 except Exception as e: 

270 db.rollback() 

271 return _err(str(e)) 

272 finally: 

273 db.close() 

274 

275 

276# ═══════════════════════════════════════════════════════════════ 

277# CHALLENGES (5 endpoints) 

278# ═══════════════════════════════════════════════════════════════ 

279 

280@gamification_bp.route('/challenges', methods=['GET']) 

281@optional_auth 

282def list_challenges(): 

283 from .gamification_service import GamificationService 

284 db = get_db() 

285 try: 

286 user_id = g.user_id if hasattr(g, 'user_id') and g.user_id else None 

287 challenges = GamificationService.get_active_challenges(db, user_id) 

288 return _ok(challenges) 

289 finally: 

290 db.close() 

291 

292 

293@gamification_bp.route('/challenges/<challenge_id>', methods=['GET']) 

294@optional_auth 

295def get_challenge(challenge_id): 

296 from .gamification_service import GamificationService 

297 db = get_db() 

298 try: 

299 user_id = g.user_id if hasattr(g, 'user_id') and g.user_id else None 

300 challenge = GamificationService.get_challenge(db, challenge_id, user_id) 

301 if not challenge: 

302 return _err("Challenge not found", 404) 

303 return _ok(challenge) 

304 finally: 

305 db.close() 

306 

307 

308@gamification_bp.route('/challenges/<challenge_id>/progress', methods=['POST']) 

309@require_auth 

310def update_challenge_progress(challenge_id): 

311 from .gamification_service import GamificationService 

312 db = get_db() 

313 try: 

314 data = _get_json() 

315 increment = data.get('increment', 1) 

316 result = GamificationService.update_challenge_progress(db, g.user_id, challenge_id, increment) 

317 if not result: 

318 return _err("Challenge not found", 404) 

319 db.commit() 

320 return _ok(result) 

321 except Exception as e: 

322 db.rollback() 

323 return _err(str(e)) 

324 finally: 

325 db.close() 

326 

327 

328@gamification_bp.route('/challenges/<challenge_id>/claim', methods=['POST']) 

329@require_auth 

330def claim_challenge_reward(challenge_id): 

331 from .gamification_service import GamificationService 

332 db = get_db() 

333 try: 

334 result = GamificationService.claim_challenge_reward(db, g.user_id, challenge_id) 

335 if not result: 

336 return _err("Challenge not completed or not found", 404) 

337 db.commit() 

338 return _ok(result) 

339 except Exception as e: 

340 db.rollback() 

341 return _err(str(e)) 

342 finally: 

343 db.close() 

344 

345 

346# ═══════════════════════════════════════════════════════════════ 

347# SEASONS (4 endpoints) 

348# ═══════════════════════════════════════════════════════════════ 

349 

350@gamification_bp.route('/seasons/current', methods=['GET']) 

351@optional_auth 

352def current_season(): 

353 from .gamification_service import GamificationService 

354 db = get_db() 

355 try: 

356 season = GamificationService.get_current_season(db) 

357 return _ok(season) 

358 finally: 

359 db.close() 

360 

361 

362@gamification_bp.route('/seasons/<season_id>/leaderboard', methods=['GET']) 

363@optional_auth 

364def season_leaderboard(season_id): 

365 from .gamification_service import GamificationService 

366 db = get_db() 

367 try: 

368 limit = min(int(request.args.get('limit', 50)), 100) 

369 offset = int(request.args.get('offset', 0)) 

370 result = GamificationService.get_season_leaderboard(db, season_id, limit, offset) 

371 return _ok(result) 

372 finally: 

373 db.close() 

374 

375 

376@gamification_bp.route('/seasons/<season_id>/achievements', methods=['GET']) 

377@optional_auth 

378def season_achievements(season_id): 

379 from .gamification_service import GamificationService 

380 db = get_db() 

381 try: 

382 result = GamificationService.get_season_achievements(db, season_id) 

383 return _ok(result) 

384 finally: 

385 db.close() 

386 

387 

388@gamification_bp.route('/collectibles/<user_id>', methods=['GET']) 

389@optional_auth 

390def user_collectibles(user_id): 

391 db = get_db() 

392 try: 

393 showcased = db.query(UserAchievement).filter_by( 

394 user_id=user_id, is_showcased=True).all() 

395 wallet = ResonanceService.get_wallet(db, user_id) 

396 return _ok({ 

397 'showcased_achievements': [ua.to_dict() for ua in showcased], 

398 'level': wallet.get('level', 1) if wallet else 1, 

399 'level_title': wallet.get('level_title', 'Newcomer') if wallet else 'Newcomer', 

400 }) 

401 finally: 

402 db.close() 

403 

404 

405# ═══════════════════════════════════════════════════════════════ 

406# REGIONS & GOVERNANCE (14 endpoints) 

407# ═══════════════════════════════════════════════════════════════ 

408 

409@gamification_bp.route('/regions', methods=['GET']) 

410@optional_auth 

411def list_regions(): 

412 from .region_service import RegionService 

413 db = get_db() 

414 try: 

415 limit = min(int(request.args.get('limit', 50)), 100) 

416 offset = int(request.args.get('offset', 0)) 

417 regions = RegionService.list_regions(db, limit=limit, offset=offset) 

418 return _ok(regions) 

419 finally: 

420 db.close() 

421 

422 

423@gamification_bp.route('/regions', methods=['POST']) 

424@require_auth 

425def create_region(): 

426 from .region_service import RegionService 

427 db = get_db() 

428 try: 

429 data = _get_json() 

430 result = RegionService.create_region(db, g.user_id, data) 

431 db.commit() 

432 return _ok(result, status=201) 

433 except Exception as e: 

434 db.rollback() 

435 return _err(str(e)) 

436 finally: 

437 db.close() 

438 

439 

440@gamification_bp.route('/regions/<region_id>', methods=['GET']) 

441@optional_auth 

442def get_region(region_id): 

443 from .region_service import RegionService 

444 db = get_db() 

445 try: 

446 region = RegionService.get_region(db, region_id) 

447 if not region: 

448 return _err('Region not found', 404) 

449 return _ok(region) 

450 finally: 

451 db.close() 

452 

453 

454@gamification_bp.route('/regions/<region_id>', methods=['PATCH']) 

455@require_auth 

456def update_region(region_id): 

457 db = get_db() 

458 try: 

459 mem = db.query(RegionMembership).filter_by( 

460 user_id=g.user_id, region_id=region_id).first() 

461 if not mem or mem.role not in ('admin', 'steward'): 

462 return _err('Insufficient privileges', 403) 

463 region = db.query(Region).filter_by(id=region_id).first() 

464 if not region: 

465 return _err('Region not found', 404) 

466 data = _get_json() 

467 for field in ('display_name', 'description', 'global_server_url', 'settings_json'): 

468 if field in data: 

469 setattr(region, field, data[field]) 

470 db.commit() 

471 return _ok(region.to_dict()) 

472 except Exception as e: 

473 db.rollback() 

474 return _err(str(e)) 

475 finally: 

476 db.close() 

477 

478 

479@gamification_bp.route('/regions/<region_id>/join', methods=['POST']) 

480@require_auth 

481def join_region(region_id): 

482 from .region_service import RegionService 

483 db = get_db() 

484 try: 

485 result = RegionService.join_region(db, g.user_id, region_id) 

486 db.commit() 

487 return _ok(result) 

488 except Exception as e: 

489 db.rollback() 

490 return _err(str(e)) 

491 finally: 

492 db.close() 

493 

494 

495@gamification_bp.route('/regions/<region_id>/leave', methods=['DELETE']) 

496@require_auth 

497def leave_region(region_id): 

498 from .region_service import RegionService 

499 db = get_db() 

500 try: 

501 result = RegionService.leave_region(db, g.user_id, region_id) 

502 db.commit() 

503 return _ok(result) 

504 except Exception as e: 

505 db.rollback() 

506 return _err(str(e)) 

507 finally: 

508 db.close() 

509 

510 

511@gamification_bp.route('/regions/<region_id>/members', methods=['GET']) 

512@optional_auth 

513def region_members(region_id): 

514 from .region_service import RegionService 

515 db = get_db() 

516 try: 

517 limit = min(int(request.args.get('limit', 50)), 100) 

518 offset = int(request.args.get('offset', 0)) 

519 result = RegionService.get_members(db, region_id, limit=limit, offset=offset) 

520 return _ok(result) 

521 finally: 

522 db.close() 

523 

524 

525@gamification_bp.route('/regions/<region_id>/feed', methods=['GET']) 

526@optional_auth 

527def region_feed(region_id): 

528 from .region_service import RegionService 

529 db = get_db() 

530 try: 

531 limit = min(int(request.args.get('limit', 20)), 50) 

532 offset = int(request.args.get('offset', 0)) 

533 result = RegionService.get_regional_feed(db, region_id, limit=limit, offset=offset) 

534 return _ok(result) 

535 finally: 

536 db.close() 

537 

538 

539@gamification_bp.route('/regions/<region_id>/leaderboard', methods=['GET']) 

540@optional_auth 

541def region_leaderboard(region_id): 

542 from .region_service import RegionService 

543 db = get_db() 

544 try: 

545 limit = min(int(request.args.get('limit', 50)), 100) 

546 offset = int(request.args.get('offset', 0)) 

547 result = RegionService.get_regional_leaderboard(db, region_id, limit=limit, offset=offset) 

548 return _ok(result) 

549 finally: 

550 db.close() 

551 

552 

553@gamification_bp.route('/regions/<region_id>/governance', methods=['GET']) 

554@optional_auth 

555def region_governance(region_id): 

556 from .region_service import RegionService 

557 db = get_db() 

558 try: 

559 result = RegionService.get_governance_info(db, region_id) 

560 if not result: 

561 return _err('Region not found', 404) 

562 return _ok(result) 

563 finally: 

564 db.close() 

565 

566 

567@gamification_bp.route('/regions/<region_id>/promote', methods=['POST']) 

568@require_auth 

569def promote_member(region_id): 

570 from .region_service import RegionService 

571 db = get_db() 

572 try: 

573 data = _get_json() 

574 target_user_id = data.get('user_id') 

575 new_role = data.get('role') 

576 result = RegionService.promote_member(db, g.user_id, region_id, target_user_id, new_role) 

577 db.commit() 

578 return _ok(result) 

579 except Exception as e: 

580 db.rollback() 

581 return _err(str(e)) 

582 finally: 

583 db.close() 

584 

585 

586@gamification_bp.route('/regions/<region_id>/demote', methods=['POST']) 

587@require_auth 

588def demote_member(region_id): 

589 from .region_service import RegionService 

590 db = get_db() 

591 try: 

592 data = _get_json() 

593 target_user_id = data.get('user_id') 

594 result = RegionService.demote_member(db, g.user_id, region_id, target_user_id) 

595 db.commit() 

596 return _ok(result) 

597 except Exception as e: 

598 db.rollback() 

599 return _err(str(e)) 

600 finally: 

601 db.close() 

602 

603 

604@gamification_bp.route('/regions/nearby', methods=['GET']) 

605@optional_auth 

606def nearby_regions(): 

607 from .region_service import RegionService 

608 db = get_db() 

609 try: 

610 lat = float(request.args.get('lat', 0)) 

611 lon = float(request.args.get('lon', 0)) 

612 radius = float(request.args.get('radius', 100)) 

613 result = RegionService.nearby_regions(db, lat, lon, radius) 

614 return _ok(result) 

615 finally: 

616 db.close() 

617 

618 

619@gamification_bp.route('/regions/<region_id>/sync', methods=['POST']) 

620@require_auth 

621def sync_region(region_id): 

622 db = get_db() 

623 try: 

624 mem = db.query(RegionMembership).filter_by( 

625 user_id=g.user_id, region_id=region_id).first() 

626 if not mem or mem.role not in ('admin', 'steward'): 

627 return _err('Insufficient privileges', 403) 

628 region = db.query(Region).filter_by(id=region_id).first() 

629 if not region or not region.global_server_url: 

630 return _err('No global server configured') 

631 # Sync with global server (placeholder - actual federation call) 

632 return _ok({'synced': True, 'global_server': region.global_server_url}) 

633 finally: 

634 db.close() 

635 

636 

637# ═══════════════════════════════════════════════════════════════ 

638# ENCOUNTERS (6 endpoints) 

639# ═══════════════════════════════════════════════════════════════ 

640 

641@gamification_bp.route('/encounters', methods=['GET']) 

642@require_auth 

643def list_encounters(): 

644 from .encounter_service import EncounterService 

645 db = get_db() 

646 try: 

647 limit = min(int(request.args.get('limit', 20)), 50) 

648 encounters = EncounterService.get_encounters(db, g.user_id, limit=limit) 

649 return _ok(encounters) 

650 finally: 

651 db.close() 

652 

653 

654@gamification_bp.route('/encounters/<user_id>', methods=['GET']) 

655@require_auth 

656def shared_encounters(user_id): 

657 from .encounter_service import EncounterService 

658 db = get_db() 

659 try: 

660 encounters = EncounterService.get_encounters_with(db, g.user_id, user_id) 

661 return _ok(encounters) 

662 finally: 

663 db.close() 

664 

665 

666@gamification_bp.route('/encounters/<encounter_id>/acknowledge', methods=['POST']) 

667@require_auth 

668def acknowledge_encounter(encounter_id): 

669 from .encounter_service import EncounterService 

670 db = get_db() 

671 try: 

672 result = EncounterService.acknowledge_encounter(db, encounter_id, g.user_id) 

673 if not result: 

674 return _err('Encounter not found', 404) 

675 db.commit() 

676 return _ok(result) 

677 except Exception as e: 

678 db.rollback() 

679 return _err(str(e)) 

680 finally: 

681 db.close() 

682 

683 

684@gamification_bp.route('/encounters/suggestions', methods=['GET']) 

685@require_auth 

686def encounter_suggestions(): 

687 from .encounter_service import EncounterService 

688 db = get_db() 

689 try: 

690 suggestions = EncounterService.get_suggestions(db, g.user_id) 

691 return _ok(suggestions) 

692 finally: 

693 db.close() 

694 

695 

696@gamification_bp.route('/encounters/bonds', methods=['GET']) 

697@require_auth 

698def encounter_bonds(): 

699 from .encounter_service import EncounterService 

700 db = get_db() 

701 try: 

702 bonds = EncounterService.get_bonds(db, g.user_id) 

703 return _ok(bonds) 

704 finally: 

705 db.close() 

706 

707 

708@gamification_bp.route('/encounters/nearby', methods=['GET']) 

709@require_auth 

710def encounters_nearby(): 

711 from .encounter_service import EncounterService 

712 db = get_db() 

713 try: 

714 result = EncounterService.get_nearby_active(db, g.user_id) 

715 return _ok(result) 

716 finally: 

717 db.close() 

718 

719 

720# ═══════════════════════════════════════════════════════════════ 

721# AGENT EVOLUTION (8 endpoints) 

722# ═══════════════════════════════════════════════════════════════ 

723 

724@gamification_bp.route('/agents/<agent_id>/evolution', methods=['GET']) 

725@optional_auth 

726def agent_evolution(agent_id): 

727 from .agent_evolution_service import AgentEvolutionService 

728 db = get_db() 

729 try: 

730 result = AgentEvolutionService.get_evolution(db, agent_id) 

731 return _ok(result) 

732 finally: 

733 db.close() 

734 

735 

736@gamification_bp.route('/agents/<agent_id>/specialize', methods=['POST']) 

737@require_auth 

738def agent_specialize(agent_id): 

739 from .agent_evolution_service import AgentEvolutionService 

740 db = get_db() 

741 try: 

742 data = _get_json() 

743 path = data.get('path') 

744 result = AgentEvolutionService.specialize(db, agent_id, path) 

745 db.commit() 

746 return _ok(result) 

747 except Exception as e: 

748 db.rollback() 

749 return _err(str(e)) 

750 finally: 

751 db.close() 

752 

753 

754@gamification_bp.route('/agents/leaderboard', methods=['GET']) 

755@optional_auth 

756def agent_leaderboard(): 

757 from .agent_evolution_service import AgentEvolutionService 

758 db = get_db() 

759 try: 

760 limit = min(int(request.args.get('limit', 50)), 100) 

761 result = AgentEvolutionService.get_agent_leaderboard(db, limit=limit) 

762 return _ok(result) 

763 finally: 

764 db.close() 

765 

766 

767SPECIALIZATION_TREES = { 

768 'analyst': {'base': 'Analyst', 'advanced': 'Oracle', 

769 'description': 'Data analysis, pattern recognition, insights'}, 

770 'creator': {'base': 'Creator', 'advanced': 'Visionary', 

771 'description': 'Content generation, creative problem solving'}, 

772 'executor': {'base': 'Executor', 'advanced': 'Automaton', 

773 'description': 'Task execution, workflow automation'}, 

774 'communicator': {'base': 'Communicator', 'advanced': 'Ambassador', 

775 'description': 'Inter-agent communication, negotiation'}, 

776} 

777 

778 

779@gamification_bp.route('/agents/specialization-trees', methods=['GET']) 

780@optional_auth 

781def specialization_trees(): 

782 return _ok(SPECIALIZATION_TREES) 

783 

784 

785@gamification_bp.route('/agents/<agent_id>/collaborations', methods=['GET']) 

786@optional_auth 

787def agent_collaborations(agent_id): 

788 from .agent_evolution_service import AgentEvolutionService 

789 db = get_db() 

790 try: 

791 limit = min(int(request.args.get('limit', 50)), 100) 

792 result = AgentEvolutionService.get_collaborations(db, agent_id, limit=limit) 

793 return _ok(result) 

794 finally: 

795 db.close() 

796 

797 

798@gamification_bp.route('/agents/<agent_id>/collaborate', methods=['POST']) 

799@require_auth 

800def record_collaboration(agent_id): 

801 from .agent_evolution_service import AgentEvolutionService 

802 db = get_db() 

803 try: 

804 data = _get_json() 

805 other_agent_id = data.get('other_agent_id') 

806 collab_type = data.get('type', 'co_task') 

807 quality = float(data.get('quality_score', 0.5)) 

808 task_id = data.get('task_id') 

809 result = AgentEvolutionService.record_collaboration( 

810 db, agent_id, other_agent_id, collab_type, quality, task_id) 

811 db.commit() 

812 return _ok(result) 

813 except Exception as e: 

814 db.rollback() 

815 return _err(str(e)) 

816 finally: 

817 db.close() 

818 

819 

820@gamification_bp.route('/agents/showcase', methods=['GET']) 

821@optional_auth 

822def agent_showcase(): 

823 from .agent_evolution_service import AgentEvolutionService 

824 db = get_db() 

825 try: 

826 limit = min(int(request.args.get('limit', 20)), 50) 

827 result = AgentEvolutionService.get_showcase(db, limit=limit) 

828 return _ok(result) 

829 finally: 

830 db.close() 

831 

832 

833@gamification_bp.route('/agents/<agent_id>/evolution-history', methods=['GET']) 

834@optional_auth 

835def agent_evolution_history(agent_id): 

836 db = get_db() 

837 try: 

838 # XP timeline from transactions 

839 txns = db.query(ResonanceTransaction).filter_by( 

840 user_id=agent_id, currency='xp' 

841 ).order_by(ResonanceTransaction.created_at).all() 

842 return _ok([t.to_dict() for t in txns]) 

843 finally: 

844 db.close() 

845 

846 

847# ═══════════════════════════════════════════════════════════════ 

848# RATINGS & TRUST (6 endpoints) 

849# ═══════════════════════════════════════════════════════════════ 

850 

851@gamification_bp.route('/ratings', methods=['POST']) 

852@require_auth 

853def submit_rating(): 

854 from .rating_service import RatingService 

855 db = get_db() 

856 try: 

857 data = _get_json() 

858 result = RatingService.submit_rating(db, g.user_id, data) 

859 db.commit() 

860 return _ok(result) 

861 except Exception as e: 

862 db.rollback() 

863 return _err(str(e)) 

864 finally: 

865 db.close() 

866 

867 

868def _recalculate_trust(db: SASession, user_id: str): 

869 """Recalculate composite trust score for a user.""" 

870 from sqlalchemy import func as sqlfunc 

871 avgs = db.query( 

872 Rating.dimension, sqlfunc.avg(Rating.score), sqlfunc.count(Rating.id) 

873 ).filter_by(rated_id=user_id).group_by(Rating.dimension).all() 

874 

875 ts = db.query(TrustScore).filter_by(user_id=user_id).first() 

876 if not ts: 

877 ts = TrustScore(user_id=user_id) 

878 db.add(ts) 

879 

880 total = 0 

881 for dim, avg_score, count in avgs: 

882 setattr(ts, f'avg_{dim}', float(avg_score)) 

883 total += count 

884 

885 ts.total_ratings_received = total 

886 # Weighted composite: skill 0.25, usefulness 0.30, reliability 0.30, creativity 0.15 

887 ts.composite_trust = ( 

888 ts.avg_skill * 0.25 + ts.avg_usefulness * 0.30 + 

889 ts.avg_reliability * 0.30 + ts.avg_creativity * 0.15 

890 ) 

891 

892 

893@gamification_bp.route('/ratings/<user_id>', methods=['GET']) 

894@optional_auth 

895def get_trust_scores(user_id): 

896 from .rating_service import RatingService 

897 db = get_db() 

898 try: 

899 result = RatingService.get_aggregated(db, user_id) 

900 return _ok(result) 

901 finally: 

902 db.close() 

903 

904 

905@gamification_bp.route('/ratings/<user_id>/received', methods=['GET']) 

906@optional_auth 

907def ratings_received(user_id): 

908 from .rating_service import RatingService 

909 db = get_db() 

910 try: 

911 limit = min(int(request.args.get('limit', 50)), 100) 

912 offset = int(request.args.get('offset', 0)) 

913 result = RatingService.get_ratings_received(db, user_id, limit=limit, offset=offset) 

914 return _ok(result) 

915 finally: 

916 db.close() 

917 

918 

919@gamification_bp.route('/ratings/<user_id>/given', methods=['GET']) 

920@require_auth 

921def ratings_given(user_id): 

922 from .rating_service import RatingService 

923 db = get_db() 

924 try: 

925 if user_id != g.user_id: 

926 return _err('Can only view your own given ratings', 403) 

927 limit = min(int(request.args.get('limit', 50)), 100) 

928 offset = int(request.args.get('offset', 0)) 

929 result = RatingService.get_ratings_given(db, user_id, limit=limit, offset=offset) 

930 return _ok(result) 

931 finally: 

932 db.close() 

933 

934 

935@gamification_bp.route('/ratings/context/<context_type>/<context_id>', methods=['GET']) 

936@optional_auth 

937def ratings_for_context(context_type, context_id): 

938 db = get_db() 

939 try: 

940 ratings = db.query(Rating).filter_by( 

941 context_type=context_type, context_id=context_id).all() 

942 return _ok([r.to_dict() for r in ratings]) 

943 finally: 

944 db.close() 

945 

946 

947@gamification_bp.route('/trust/<user_id>', methods=['GET']) 

948@optional_auth 

949def trust_card(user_id): 

950 from .rating_service import RatingService 

951 db = get_db() 

952 try: 

953 result = RatingService.get_trust_score(db, user_id) 

954 if not result: 

955 return _err('User not found', 404) 

956 return _ok(result) 

957 finally: 

958 db.close() 

959 

960 

961# ═══════════════════════════════════════════════════════════════ 

962# DISTRIBUTION (8 endpoints) 

963# ═══════════════════════════════════════════════════════════════ 

964 

965@gamification_bp.route('/referral/code', methods=['GET']) 

966@require_auth 

967def get_referral_code(): 

968 from .distribution_service import DistributionService 

969 db = get_db() 

970 try: 

971 result = DistributionService.get_or_create_referral_code(db, g.user_id) 

972 # Trigger onboarding step when user first shares their referral code 

973 try: 

974 from .onboarding_service import OnboardingService 

975 OnboardingService.auto_advance(db, g.user_id, 'share_referral') 

976 except Exception: 

977 pass 

978 db.commit() 

979 return _ok(result) 

980 except Exception as e: 

981 db.rollback() 

982 return _err(str(e)) 

983 finally: 

984 db.close() 

985 

986 

987@gamification_bp.route('/referral/use', methods=['POST']) 

988@require_auth 

989def use_referral_code(): 

990 from .distribution_service import DistributionService 

991 db = get_db() 

992 try: 

993 data = _get_json() 

994 code = data.get('code', '') 

995 result = DistributionService.use_referral_code(db, g.user_id, code) 

996 db.commit() 

997 return _ok(result) 

998 except Exception as e: 

999 db.rollback() 

1000 return _err(str(e)) 

1001 finally: 

1002 db.close() 

1003 

1004 

1005@gamification_bp.route('/referral/stats', methods=['GET']) 

1006@require_auth 

1007def referral_stats(): 

1008 from .distribution_service import DistributionService 

1009 db = get_db() 

1010 try: 

1011 result = DistributionService.get_referral_stats(db, g.user_id) 

1012 return _ok(result) 

1013 finally: 

1014 db.close() 

1015 

1016 

1017@gamification_bp.route('/marketplace/recipes', methods=['GET']) 

1018@optional_auth 

1019def marketplace_recipes(): 

1020 db = get_db() 

1021 try: 

1022 from .models import RecipeShare 

1023 limit = min(int(request.args.get('limit', 20)), 50) 

1024 recipes = db.query(RecipeShare).order_by( 

1025 RecipeShare.fork_count.desc()).limit(limit).all() 

1026 return _ok([r.to_dict() for r in recipes]) 

1027 finally: 

1028 db.close() 

1029 

1030 

1031@gamification_bp.route('/marketplace/agents', methods=['GET']) 

1032@optional_auth 

1033def marketplace_agents(): 

1034 db = get_db() 

1035 try: 

1036 limit = min(int(request.args.get('limit', 20)), 50) 

1037 agents = db.query(User).filter_by( 

1038 user_type='agent', is_banned=False 

1039 ).order_by(User.karma_score.desc()).limit(limit).all() 

1040 return _ok([a.to_dict() for a in agents]) 

1041 finally: 

1042 db.close() 

1043 

1044 

1045# ── Marketplace HART Listings (CRUD + hire + reviews) ── 

1046 

1047MARKETPLACE_CATEGORIES = [ 

1048 'content_creation', 'analysis_research', 'learning_tutoring', 

1049 'game_design', 'creative', 'custom', 

1050] 

1051 

1052 

1053@gamification_bp.route('/marketplace/listings', methods=['GET']) 

1054@optional_auth 

1055def list_listings(): 

1056 """List active marketplace listings with optional filters.""" 

1057 from .models import MarketplaceListing 

1058 db = get_db() 

1059 try: 

1060 limit = min(int(request.args.get('limit', 20)), 50) 

1061 offset = int(request.args.get('offset', 0)) 

1062 category = request.args.get('category') 

1063 q = request.args.get('q', '').strip() 

1064 

1065 query = db.query(MarketplaceListing).filter_by(is_active=True) 

1066 if category: 

1067 query = query.filter_by(category=category) 

1068 if q: 

1069 query = query.filter( 

1070 MarketplaceListing.title.ilike(f'%{q}%') | 

1071 MarketplaceListing.description.ilike(f'%{q}%') 

1072 ) 

1073 total = query.count() 

1074 listings = query.order_by( 

1075 MarketplaceListing.hire_count.desc(), 

1076 MarketplaceListing.rating_avg.desc(), 

1077 ).offset(offset).limit(limit).all() 

1078 return _ok([l.to_dict() for l in listings], _paginate(total, limit, offset)) 

1079 except Exception as e: 

1080 logger.error(f"marketplace/listings GET error: {e}") 

1081 return _err(str(e), 500) 

1082 finally: 

1083 db.close() 

1084 

1085 

1086@gamification_bp.route('/marketplace/listings/<listing_id>', methods=['GET']) 

1087@optional_auth 

1088def get_listing(listing_id): 

1089 """Get a single marketplace listing by ID.""" 

1090 from .models import MarketplaceListing 

1091 db = get_db() 

1092 try: 

1093 listing = db.query(MarketplaceListing).filter_by(id=listing_id).first() 

1094 if not listing: 

1095 return _err('Listing not found', 404) 

1096 return _ok(listing.to_dict()) 

1097 except Exception as e: 

1098 logger.error(f"marketplace/listings/{listing_id} GET error: {e}") 

1099 return _err(str(e), 500) 

1100 finally: 

1101 db.close() 

1102 

1103 

1104@gamification_bp.route('/marketplace/listings', methods=['POST']) 

1105@require_auth 

1106def create_listing(): 

1107 """Create a new marketplace listing for an agent owned by the current user.""" 

1108 from .models import MarketplaceListing 

1109 db = get_db() 

1110 try: 

1111 data = _get_json() 

1112 agent_id = data.get('agent_id') 

1113 if not agent_id: 

1114 return _err('agent_id required') 

1115 

1116 agent = db.query(User).filter_by(id=agent_id, user_type='agent').first() 

1117 if not agent: 

1118 return _err('Agent not found', 404) 

1119 if agent.owner_id and agent.owner_id != g.user_id: 

1120 return _err('Not your agent', 403) 

1121 

1122 title = data.get('title', '').strip() 

1123 if not title: 

1124 return _err('title required') 

1125 

1126 listing = MarketplaceListing( 

1127 agent_id=agent_id, 

1128 title=title, 

1129 description=data.get('description', ''), 

1130 category=data.get('category', 'custom'), 

1131 price_spark=max(0, int(data.get('price_spark', 0))), 

1132 ) 

1133 db.add(listing) 

1134 db.commit() 

1135 db.refresh(listing) 

1136 return _ok(listing.to_dict(), status=201) 

1137 except Exception as e: 

1138 db.rollback() 

1139 logger.error(f"marketplace/listings POST error: {e}") 

1140 return _err(str(e), 500) 

1141 finally: 

1142 db.close() 

1143 

1144 

1145@gamification_bp.route('/marketplace/listings/<listing_id>/hire', methods=['POST']) 

1146@require_auth 

1147def hire_listing(listing_id): 

1148 """Hire a HART via a marketplace listing. Increments hire_count.""" 

1149 from .models import MarketplaceListing 

1150 db = get_db() 

1151 try: 

1152 listing = db.query(MarketplaceListing).filter_by(id=listing_id, is_active=True).first() 

1153 if not listing: 

1154 return _err('Listing not found or inactive', 404) 

1155 

1156 listing.hire_count = (listing.hire_count or 0) + 1 

1157 db.commit() 

1158 return _ok({ 

1159 'listing_id': listing.id, 

1160 'agent_id': listing.agent_id, 

1161 'hire_count': listing.hire_count, 

1162 }) 

1163 except Exception as e: 

1164 db.rollback() 

1165 logger.error(f"marketplace/listings/{listing_id}/hire error: {e}") 

1166 return _err(str(e), 500) 

1167 finally: 

1168 db.close() 

1169 

1170 

1171@gamification_bp.route('/marketplace/listings/<listing_id>/reviews', methods=['GET']) 

1172@optional_auth 

1173def list_reviews(listing_id): 

1174 """Get reviews for a marketplace listing.""" 

1175 from .models import ListingReview 

1176 db = get_db() 

1177 try: 

1178 limit = min(int(request.args.get('limit', 20)), 50) 

1179 offset = int(request.args.get('offset', 0)) 

1180 query = db.query(ListingReview).filter_by(listing_id=listing_id) 

1181 total = query.count() 

1182 reviews = query.order_by(ListingReview.created_at.desc()).offset(offset).limit(limit).all() 

1183 return _ok([r.to_dict() for r in reviews], _paginate(total, limit, offset)) 

1184 except Exception as e: 

1185 logger.error(f"reviews GET error: {e}") 

1186 return _err(str(e), 500) 

1187 finally: 

1188 db.close() 

1189 

1190 

1191@gamification_bp.route('/marketplace/listings/<listing_id>/reviews', methods=['POST']) 

1192@require_auth 

1193def add_review(listing_id): 

1194 """Add a review to a marketplace listing. One review per user per listing.""" 

1195 from .models import MarketplaceListing, ListingReview 

1196 from sqlalchemy import func as sa_func 

1197 db = get_db() 

1198 try: 

1199 listing = db.query(MarketplaceListing).filter_by(id=listing_id).first() 

1200 if not listing: 

1201 return _err('Listing not found', 404) 

1202 

1203 data = _get_json() 

1204 rating = int(data.get('rating', 0)) 

1205 if rating < 1 or rating > 5: 

1206 return _err('rating must be 1-5') 

1207 

1208 existing = db.query(ListingReview).filter_by( 

1209 listing_id=listing_id, user_id=g.user_id 

1210 ).first() 

1211 if existing: 

1212 return _err('Already reviewed this listing', 409) 

1213 

1214 review = ListingReview( 

1215 listing_id=listing_id, 

1216 user_id=g.user_id, 

1217 rating=rating, 

1218 text=data.get('text', ''), 

1219 ) 

1220 db.add(review) 

1221 

1222 listing.review_count = (listing.review_count or 0) + 1 

1223 all_ratings = db.query(sa_func.avg(ListingReview.rating)).filter_by( 

1224 listing_id=listing_id 

1225 ).scalar() 

1226 listing.rating_avg = round(float(all_ratings or rating), 2) 

1227 

1228 db.commit() 

1229 db.refresh(review) 

1230 return _ok(review.to_dict(), status=201) 

1231 except Exception as e: 

1232 db.rollback() 

1233 logger.error(f"reviews POST error: {e}") 

1234 return _err(str(e), 500) 

1235 finally: 

1236 db.close() 

1237 

1238 

1239@gamification_bp.route('/marketplace/categories', methods=['GET']) 

1240@optional_auth 

1241def list_marketplace_categories(): 

1242 """List available marketplace categories.""" 

1243 return _ok(MARKETPLACE_CATEGORIES) 

1244 

1245 

1246@gamification_bp.route('/share/generate-link', methods=['POST']) 

1247@require_auth 

1248def generate_share_link(): 

1249 db = get_db() 

1250 try: 

1251 data = _get_json() 

1252 target_type = data.get('target_type') 

1253 target_id = data.get('target_id') 

1254 # Get user's referral code 

1255 user = db.query(User).filter_by(id=g.user_id).first() 

1256 ref_code = user.referral_code if user else '' 

1257 base_url = data.get('base_url', 'https://hevolve.ai') 

1258 link = f"{base_url}/{target_type}/{target_id}?ref={ref_code}" 

1259 return _ok({'link': link, 'referral_code': ref_code}) 

1260 finally: 

1261 db.close() 

1262 

1263 

1264@gamification_bp.route('/federation/contribution', methods=['GET']) 

1265@optional_auth 

1266def federation_contribution(): 

1267 db = get_db() 

1268 try: 

1269 from .models import PeerNode 

1270 nodes = db.query(PeerNode).filter_by( 

1271 status='active' 

1272 ).order_by(PeerNode.contribution_score.desc()).limit(50).all() 

1273 return _ok([n.to_dict() for n in nodes]) 

1274 finally: 

1275 db.close() 

1276 

1277 

1278@gamification_bp.route('/growth/stats', methods=['GET']) 

1279@require_admin 

1280def growth_stats(): 

1281 db = get_db() 

1282 try: 

1283 total_users = db.query(User).count() 

1284 total_agents = db.query(User).filter_by(user_type='agent').count() 

1285 total_wallets = db.query(ResonanceWallet).count() 

1286 total_regions = db.query(Region).count() 

1287 total_campaigns = db.query(Campaign).count() 

1288 return _ok({ 

1289 'total_users': total_users, 

1290 'total_agents': total_agents, 

1291 'total_wallets': total_wallets, 

1292 'total_regions': total_regions, 

1293 'total_campaigns': total_campaigns, 

1294 }) 

1295 finally: 

1296 db.close() 

1297 

1298 

1299# ═══════════════════════════════════════════════════════════════ 

1300# CAMPAIGNS — "Make Me Viral" (8 endpoints) 

1301# ═══════════════════════════════════════════════════════════════ 

1302 

1303@gamification_bp.route('/campaigns', methods=['POST']) 

1304@require_auth 

1305def create_campaign(): 

1306 from .campaign_service import CampaignService 

1307 db = get_db() 

1308 try: 

1309 data = _get_json() 

1310 result = CampaignService.create_campaign(db, g.user_id, data) 

1311 db.commit() 

1312 return _ok(result, status=201) 

1313 except Exception as e: 

1314 db.rollback() 

1315 return _err(str(e)) 

1316 finally: 

1317 db.close() 

1318 

1319 

1320@gamification_bp.route('/campaigns', methods=['GET']) 

1321@require_auth 

1322def list_campaigns(): 

1323 from .campaign_service import CampaignService 

1324 db = get_db() 

1325 try: 

1326 limit = min(int(request.args.get('limit', 50)), 100) 

1327 offset = int(request.args.get('offset', 0)) 

1328 result = CampaignService.list_campaigns(db, owner_id=g.user_id, limit=limit, offset=offset) 

1329 return _ok(result) 

1330 finally: 

1331 db.close() 

1332 

1333 

1334@gamification_bp.route('/campaigns/<campaign_id>', methods=['GET']) 

1335@require_auth 

1336def get_campaign(campaign_id): 

1337 from .campaign_service import CampaignService 

1338 db = get_db() 

1339 try: 

1340 result = CampaignService.get_campaign(db, campaign_id) 

1341 if not result: 

1342 return _err('Campaign not found', 404) 

1343 return _ok(result) 

1344 finally: 

1345 db.close() 

1346 

1347 

1348@gamification_bp.route('/campaigns/<campaign_id>', methods=['PATCH']) 

1349@require_auth 

1350def update_campaign(campaign_id): 

1351 from .campaign_service import CampaignService 

1352 db = get_db() 

1353 try: 

1354 data = _get_json() 

1355 result = CampaignService.update_campaign(db, campaign_id, g.user_id, data) 

1356 db.commit() 

1357 return _ok(result) 

1358 except Exception as e: 

1359 db.rollback() 

1360 return _err(str(e)) 

1361 finally: 

1362 db.close() 

1363 

1364 

1365@gamification_bp.route('/campaigns/<campaign_id>/generate-strategy', methods=['POST']) 

1366@require_auth 

1367def generate_campaign_strategy(campaign_id): 

1368 from .campaign_service import CampaignService 

1369 db = get_db() 

1370 try: 

1371 result = CampaignService.generate_strategy(db, campaign_id, g.user_id) 

1372 db.commit() 

1373 return _ok(result) 

1374 except Exception as e: 

1375 db.rollback() 

1376 return _err(str(e)) 

1377 finally: 

1378 db.close() 

1379 

1380 

1381@gamification_bp.route('/campaigns/<campaign_id>/execute-step', methods=['POST']) 

1382@require_auth 

1383def execute_campaign_step(campaign_id): 

1384 from .campaign_service import CampaignService 

1385 db = get_db() 

1386 try: 

1387 result = CampaignService.execute_campaign_step(db, campaign_id, g.user_id) 

1388 db.commit() 

1389 return _ok(result) 

1390 except Exception as e: 

1391 db.rollback() 

1392 return _err(str(e)) 

1393 finally: 

1394 db.close() 

1395 

1396 

1397@gamification_bp.route('/campaigns/leaderboard', methods=['GET']) 

1398@optional_auth 

1399def campaign_leaderboard(): 

1400 from .campaign_service import CampaignService 

1401 db = get_db() 

1402 try: 

1403 limit = min(int(request.args.get('limit', 20)), 50) 

1404 offset = int(request.args.get('offset', 0)) 

1405 result = CampaignService.get_leaderboard(db, limit=limit, offset=offset) 

1406 return _ok(result) 

1407 finally: 

1408 db.close() 

1409 

1410 

1411@gamification_bp.route('/campaigns/<campaign_id>', methods=['DELETE']) 

1412@require_auth 

1413def delete_campaign(campaign_id): 

1414 from .campaign_service import CampaignService 

1415 db = get_db() 

1416 try: 

1417 result = CampaignService.delete_campaign(db, campaign_id, g.user_id) 

1418 db.commit() 

1419 return _ok(result) 

1420 except Exception as e: 

1421 db.rollback() 

1422 return _err(str(e)) 

1423 finally: 

1424 db.close() 

1425 

1426 

1427# ═══════════════════════════════════════════════════════════════ 

1428# ONBOARDING (4 endpoints) 

1429# ═══════════════════════════════════════════════════════════════ 

1430 

1431ONBOARDING_STEPS = [ 

1432 'welcome', 'pick_interests', 'claim_handle', 'follow_friends', 

1433 'first_interaction', 'join_community', 'create_something', 

1434] 

1435 

1436 

1437@gamification_bp.route('/onboarding/progress', methods=['GET']) 

1438@require_auth 

1439def onboarding_progress(): 

1440 from .onboarding_service import OnboardingService 

1441 db = get_db() 

1442 try: 

1443 result = OnboardingService.get_progress(db, g.user_id) 

1444 db.commit() 

1445 return _ok(result) 

1446 finally: 

1447 db.close() 

1448 

1449 

1450@gamification_bp.route('/onboarding/complete-step', methods=['POST']) 

1451@require_auth 

1452def complete_onboarding_step(): 

1453 from .onboarding_service import OnboardingService 

1454 db = get_db() 

1455 try: 

1456 data = _get_json() 

1457 step = data.get('step') 

1458 result = OnboardingService.complete_step(db, g.user_id, step) 

1459 db.commit() 

1460 return _ok(result) 

1461 except Exception as e: 

1462 db.rollback() 

1463 return _err(str(e)) 

1464 finally: 

1465 db.close() 

1466 

1467 

1468@gamification_bp.route('/onboarding/dismiss', methods=['POST']) 

1469@require_auth 

1470def dismiss_onboarding(): 

1471 from .onboarding_service import OnboardingService 

1472 db = get_db() 

1473 try: 

1474 result = OnboardingService.dismiss(db, g.user_id) 

1475 db.commit() 

1476 return _ok(result) 

1477 except Exception as e: 

1478 db.rollback() 

1479 return _err(str(e)) 

1480 finally: 

1481 db.close() 

1482 

1483 

1484@gamification_bp.route('/onboarding/suggestion', methods=['GET']) 

1485@require_auth 

1486def onboarding_suggestion(): 

1487 from .onboarding_service import OnboardingService 

1488 db = get_db() 

1489 try: 

1490 result = OnboardingService.get_suggestion(db, g.user_id) 

1491 return _ok(result) 

1492 finally: 

1493 db.close() 

1494 

1495 

1496# ── Proximity & Missed Connections ────────────────────────────── 

1497 

1498@gamification_bp.route('/encounters/location-ping', methods=['POST']) 

1499@require_auth 

1500def location_ping(): 

1501 from .proximity_service import ProximityService 

1502 data = request.get_json(force=True, silent=True) or {} 

1503 lat = data.get('lat') 

1504 lon = data.get('lon') 

1505 accuracy = data.get('accuracy', 0) 

1506 if lat is None or lon is None: 

1507 return _err("lat and lon required", 400) 

1508 try: 

1509 lat, lon = float(lat), float(lon) 

1510 except (TypeError, ValueError): 

1511 return _err("Invalid coordinates", 400) 

1512 if not (-90 <= lat <= 90 and -180 <= lon <= 180): 

1513 return _err("Coordinates out of range", 400) 

1514 db = get_db() 

1515 try: 

1516 device_id = data.get('device_id') 

1517 result = ProximityService.update_location(db, g.user_id, lat, lon, accuracy, device_id=device_id) 

1518 db.commit() 

1519 return _ok(result) 

1520 except Exception as e: 

1521 db.rollback() 

1522 return _err(str(e)) 

1523 finally: 

1524 db.close() 

1525 

1526 

1527@gamification_bp.route('/encounters/nearby-now', methods=['GET']) 

1528@require_auth 

1529def nearby_now(): 

1530 from .proximity_service import ProximityService 

1531 db = get_db() 

1532 try: 

1533 count = ProximityService.get_nearby_count(db, g.user_id) 

1534 return _ok({'nearby_count': count}) 

1535 finally: 

1536 db.close() 

1537 

1538 

1539@gamification_bp.route('/encounters/proximity-matches', methods=['GET']) 

1540@require_auth 

1541def proximity_matches(): 

1542 from .proximity_service import ProximityService 

1543 db = get_db() 

1544 try: 

1545 status = request.args.get('status') 

1546 matches = ProximityService.get_matches(db, g.user_id, status=status) 

1547 return _ok(matches) 

1548 finally: 

1549 db.close() 

1550 

1551 

1552@gamification_bp.route('/encounters/proximity/<match_id>/reveal', methods=['POST']) 

1553@require_auth 

1554def proximity_reveal(match_id): 

1555 from .proximity_service import ProximityService 

1556 db = get_db() 

1557 try: 

1558 result = ProximityService.reveal_self(db, match_id, g.user_id) 

1559 db.commit() 

1560 return _ok(result) 

1561 except ValueError as e: 

1562 db.rollback() 

1563 return _err(str(e), 400) 

1564 except Exception as e: 

1565 db.rollback() 

1566 return _err(str(e)) 

1567 finally: 

1568 db.close() 

1569 

1570 

1571@gamification_bp.route('/encounters/location-settings', methods=['GET', 'PATCH']) 

1572@require_auth 

1573def location_settings(): 

1574 from .proximity_service import ProximityService 

1575 db = get_db() 

1576 try: 

1577 if request.method == 'GET': 

1578 result = ProximityService.get_location_settings(db, g.user_id) 

1579 return _ok(result) 

1580 else: 

1581 data = request.get_json(force=True, silent=True) or {} 

1582 enabled = data.get('location_sharing_enabled', False) 

1583 result = ProximityService.update_location_settings(db, g.user_id, enabled) 

1584 db.commit() 

1585 return _ok(result) 

1586 except ValueError as e: 

1587 db.rollback() 

1588 return _err(str(e), 400) 

1589 except Exception as e: 

1590 db.rollback() 

1591 return _err(str(e)) 

1592 finally: 

1593 db.close() 

1594 

1595 

1596@gamification_bp.route('/encounters/missed-connections', methods=['GET', 'POST']) 

1597@require_auth 

1598def missed_connections(): 

1599 from .proximity_service import ProximityService 

1600 db = get_db() 

1601 try: 

1602 if request.method == 'POST': 

1603 data = request.get_json(force=True, silent=True) or {} 

1604 result = ProximityService.create_missed_connection( 

1605 db, g.user_id, 

1606 lat=float(data.get('lat', 0)), 

1607 lon=float(data.get('lon', 0)), 

1608 location_name=data.get('location_name', ''), 

1609 description=data.get('description', ''), 

1610 was_at_iso=data.get('was_at', ''), 

1611 ) 

1612 db.commit() 

1613 return _ok(result), 201 

1614 else: 

1615 lat = request.args.get('lat', type=float) 

1616 lon = request.args.get('lon', type=float) 

1617 radius = request.args.get('radius', 1000, type=float) 

1618 limit = request.args.get('limit', 20, type=int) 

1619 offset = request.args.get('offset', 0, type=int) 

1620 sort = request.args.get('sort', 'recent') 

1621 if lat is None or lon is None: 

1622 return _err("lat and lon required for search", 400) 

1623 result = ProximityService.search_missed_connections( 

1624 db, lat, lon, radius, limit=min(limit, 50), offset=offset, 

1625 exclude_user_id=None, sort=sort) 

1626 return _ok(result['data'], meta=result['meta']) 

1627 except ValueError as e: 

1628 db.rollback() 

1629 return _err(str(e), 400) 

1630 except Exception as e: 

1631 db.rollback() 

1632 return _err(str(e)) 

1633 finally: 

1634 db.close() 

1635 

1636 

1637@gamification_bp.route('/encounters/missed-connections/mine', methods=['GET']) 

1638@require_auth 

1639def my_missed_connections(): 

1640 from .proximity_service import ProximityService 

1641 db = get_db() 

1642 try: 

1643 limit = request.args.get('limit', 20, type=int) 

1644 offset = request.args.get('offset', 0, type=int) 

1645 result = ProximityService.get_my_missed_connections(db, g.user_id, limit=min(limit, 50), offset=offset) 

1646 return _ok(result['data'], meta=result['meta']) 

1647 finally: 

1648 db.close() 

1649 

1650 

1651@gamification_bp.route('/encounters/missed-connections/<missed_id>', methods=['GET', 'DELETE']) 

1652@require_auth 

1653def missed_connection_detail(missed_id): 

1654 from .proximity_service import ProximityService 

1655 db = get_db() 

1656 try: 

1657 if request.method == 'DELETE': 

1658 result = ProximityService.delete_missed_connection(db, missed_id, g.user_id) 

1659 db.commit() 

1660 return _ok(result) 

1661 else: 

1662 result = ProximityService.get_missed_with_responses(db, missed_id, viewer_id=g.user_id) 

1663 return _ok(result) 

1664 except ValueError as e: 

1665 db.rollback() 

1666 return _err(str(e), 400) 

1667 except Exception as e: 

1668 db.rollback() 

1669 return _err(str(e)) 

1670 finally: 

1671 db.close() 

1672 

1673 

1674@gamification_bp.route('/encounters/missed-connections/<missed_id>/respond', methods=['POST']) 

1675@require_auth 

1676def respond_missed_connection(missed_id): 

1677 from .proximity_service import ProximityService 

1678 data = request.get_json(force=True, silent=True) or {} 

1679 db = get_db() 

1680 try: 

1681 result = ProximityService.respond_to_missed(db, missed_id, g.user_id, data.get('message', '')) 

1682 db.commit() 

1683 return _ok(result), 201 

1684 except ValueError as e: 

1685 db.rollback() 

1686 return _err(str(e), 400) 

1687 except Exception as e: 

1688 db.rollback() 

1689 return _err(str(e)) 

1690 finally: 

1691 db.close() 

1692 

1693 

1694@gamification_bp.route('/encounters/missed-connections/<missed_id>/accept/<response_id>', methods=['POST']) 

1695@require_auth 

1696def accept_missed_response(missed_id, response_id): 

1697 from .proximity_service import ProximityService 

1698 db = get_db() 

1699 try: 

1700 result = ProximityService.accept_missed_response(db, missed_id, response_id, g.user_id) 

1701 db.commit() 

1702 return _ok(result) 

1703 except ValueError as e: 

1704 db.rollback() 

1705 return _err(str(e), 400) 

1706 except Exception as e: 

1707 db.rollback() 

1708 return _err(str(e)) 

1709 finally: 

1710 db.close() 

1711 

1712 

1713@gamification_bp.route('/encounters/missed-connections/suggest-locations', methods=['GET']) 

1714@require_auth 

1715def suggest_locations(): 

1716 from .proximity_service import ProximityService 

1717 lat = request.args.get('lat', type=float) 

1718 lon = request.args.get('lon', type=float) 

1719 if lat is None or lon is None: 

1720 return _err("lat and lon required", 400) 

1721 db = get_db() 

1722 try: 

1723 result = ProximityService.auto_suggest_locations(db, lat, lon) 

1724 return _ok(result) 

1725 finally: 

1726 db.close() 

1727 

1728 

1729# ═══════════════════════════════════════════════════════════════ 

1730# ADS (7 endpoints) 

1731# ═══════════════════════════════════════════════════════════════ 

1732 

1733@gamification_bp.route('/ads', methods=['POST']) 

1734@require_auth 

1735def create_ad(): 

1736 from .ad_service import AdService 

1737 db = get_db() 

1738 try: 

1739 data = _get_json() 

1740 result = AdService.create_ad( 

1741 db, g.user_id, 

1742 title=data.get('title', ''), 

1743 click_url=data.get('click_url', ''), 

1744 content=data.get('content', ''), 

1745 image_url=data.get('image_url', ''), 

1746 ad_type=data.get('ad_type', 'banner'), 

1747 targeting=data.get('targeting'), 

1748 budget_spark=int(data.get('budget_spark', 100)), 

1749 cost_per_impression=float(data.get('cost_per_impression', 0.1)), 

1750 cost_per_click=float(data.get('cost_per_click', 1.0)), 

1751 starts_at=data.get('starts_at'), 

1752 ends_at=data.get('ends_at'), 

1753 ) 

1754 if 'error' in result: 

1755 return _err(result['error']) 

1756 db.commit() 

1757 return _ok(result, status=201) 

1758 except Exception as e: 

1759 db.rollback() 

1760 return _err(str(e)) 

1761 finally: 

1762 db.close() 

1763 

1764 

1765@gamification_bp.route('/ads/serve', methods=['GET']) 

1766@optional_auth 

1767def serve_ad(): 

1768 from .ad_service import AdService 

1769 db = get_db() 

1770 try: 

1771 user_id = g.user_id if hasattr(g, 'user_id') and g.user_id else None 

1772 region_id = request.args.get('region') 

1773 placement = request.args.get('placement', 'feed_top') 

1774 node_id = request.args.get('node_id') 

1775 result = AdService.serve_ad(db, user_id, region_id, placement, node_id) 

1776 if not result: 

1777 return _ok(None) 

1778 return _ok(result) 

1779 finally: 

1780 db.close() 

1781 

1782 

1783@gamification_bp.route('/ads/<ad_id>/impression', methods=['POST']) 

1784@require_auth 

1785def record_ad_impression(ad_id): 

1786 from .ad_service import AdService 

1787 import hashlib 

1788 db = get_db() 

1789 try: 

1790 data = _get_json() 

1791 user_id = g.user_id 

1792 node_id = data.get('node_id') 

1793 region_id = data.get('region_id') 

1794 placement_id = data.get('placement_id') 

1795 ip_raw = request.remote_addr or '' 

1796 ip_hash = hashlib.sha256(ip_raw.encode()).hexdigest()[:16] 

1797 result = AdService.record_impression( 

1798 db, ad_id, user_id, node_id, region_id, placement_id, ip_hash) 

1799 if not result: 

1800 return _err('Ad not found', 404) 

1801 if 'error' in result: 

1802 return _err(result['error'], 429) 

1803 db.commit() 

1804 return _ok(result) 

1805 except Exception as e: 

1806 db.rollback() 

1807 return _err(str(e)) 

1808 finally: 

1809 db.close() 

1810 

1811 

1812@gamification_bp.route('/ads/<ad_id>/click', methods=['POST']) 

1813@require_auth 

1814def record_ad_click(ad_id): 

1815 from .ad_service import AdService 

1816 import hashlib 

1817 db = get_db() 

1818 try: 

1819 data = _get_json() 

1820 user_id = g.user_id 

1821 node_id = data.get('node_id') 

1822 ip_raw = request.remote_addr or '' 

1823 ip_hash = hashlib.sha256(ip_raw.encode()).hexdigest()[:16] 

1824 result = AdService.record_click(db, ad_id, user_id, node_id, ip_hash) 

1825 if not result: 

1826 return _err('Ad not found', 404) 

1827 if 'error' in result: 

1828 return _err(result['error'], 429) 

1829 db.commit() 

1830 return _ok(result) 

1831 except Exception as e: 

1832 db.rollback() 

1833 return _err(str(e)) 

1834 finally: 

1835 db.close() 

1836 

1837 

1838@gamification_bp.route('/ads/mine', methods=['GET']) 

1839@require_auth 

1840def list_my_ads(): 

1841 from .ad_service import AdService 

1842 db = get_db() 

1843 try: 

1844 status = request.args.get('status') 

1845 limit = min(int(request.args.get('limit', 25)), 100) 

1846 offset = int(request.args.get('offset', 0)) 

1847 ads = AdService.list_my_ads(db, g.user_id, status, limit, offset) 

1848 return _ok(ads) 

1849 finally: 

1850 db.close() 

1851 

1852 

1853@gamification_bp.route('/ads/<ad_id>/analytics', methods=['GET']) 

1854@require_auth 

1855def ad_analytics(ad_id): 

1856 from .ad_service import AdService 

1857 db = get_db() 

1858 try: 

1859 result = AdService.get_analytics(db, ad_id, g.user_id) 

1860 if not result: 

1861 return _err('Ad not found', 404) 

1862 return _ok(result) 

1863 finally: 

1864 db.close() 

1865 

1866 

1867@gamification_bp.route('/ads/<ad_id>', methods=['DELETE']) 

1868@require_auth 

1869def delete_ad(ad_id): 

1870 from .ad_service import AdService 

1871 db = get_db() 

1872 try: 

1873 result = AdService.delete_ad(db, ad_id, g.user_id) 

1874 if not result: 

1875 return _err('Ad not found', 404) 

1876 db.commit() 

1877 return _ok(result) 

1878 except Exception as e: 

1879 db.rollback() 

1880 return _err(str(e)) 

1881 finally: 

1882 db.close() 

1883 

1884 

1885# ═══════════════════════════════════════════════════════════════ 

1886# HOSTING REWARDS (3 endpoints) 

1887# ═══════════════════════════════════════════════════════════════ 

1888 

1889@gamification_bp.route('/hosting/rewards', methods=['GET']) 

1890@require_auth 

1891def hosting_rewards(): 

1892 from .hosting_reward_service import HostingRewardService 

1893 db = get_db() 

1894 try: 

1895 node_id = request.args.get('node_id') 

1896 limit = min(int(request.args.get('limit', 50)), 100) 

1897 offset = int(request.args.get('offset', 0)) 

1898 rewards = HostingRewardService.get_rewards( 

1899 db, node_id=node_id, operator_id=g.user_id, limit=limit, offset=offset) 

1900 summary = None 

1901 if node_id: 

1902 summary = HostingRewardService.get_reward_summary(db, node_id) 

1903 return _ok({'rewards': rewards, 'summary': summary}) 

1904 finally: 

1905 db.close() 

1906 

1907 

1908@gamification_bp.route('/hosting/leaderboard', methods=['GET']) 

1909@optional_auth 

1910def hosting_leaderboard(): 

1911 from .hosting_reward_service import HostingRewardService 

1912 db = get_db() 

1913 try: 

1914 limit = min(int(request.args.get('limit', 50)), 100) 

1915 offset = int(request.args.get('offset', 0)) 

1916 result = HostingRewardService.get_leaderboard(db, limit, offset) 

1917 return _ok(result) 

1918 finally: 

1919 db.close() 

1920 

1921 

1922@gamification_bp.route('/hosting/compute-rewards', methods=['POST']) 

1923@require_admin 

1924def compute_hosting_rewards(): 

1925 from .hosting_reward_service import HostingRewardService 

1926 db = get_db() 

1927 try: 

1928 period_days = int(request.args.get('period_days', 7)) 

1929 scores = HostingRewardService.compute_all_scores(db, period_days) 

1930 # Distribute uptime bonuses and check milestones for each node 

1931 bonuses = [] 

1932 milestones = [] 

1933 for s in scores: 

1934 bonus = HostingRewardService.distribute_uptime_bonus(db, s['node_id']) 

1935 if bonus: 

1936 bonuses.append(bonus) 

1937 milestone = HostingRewardService.check_milestones(db, s['node_id']) 

1938 if milestone: 

1939 milestones.append(milestone) 

1940 db.commit() 

1941 return _ok({ 

1942 'scores_computed': len(scores), 

1943 'uptime_bonuses': len(bonuses), 

1944 'milestones_awarded': len(milestones), 

1945 'details': scores, 

1946 }) 

1947 except Exception as e: 

1948 db.rollback() 

1949 return _err(str(e)) 

1950 finally: 

1951 db.close()