Coverage for integrations / social / proximity_service.py: 80.9%

320 statements  

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

1""" 

2HevolveSocial - Proximity & Missed Connections Service 

3Same Place Same Time geolocation encounters. 

4""" 

5import math 

6import logging 

7from datetime import datetime, timedelta 

8 

9logger = logging.getLogger('hevolve_social') 

10 

11PROXIMITY_RADIUS_M = 100 # Default detection radius 

12PING_TTL_HOURS = 24 

13MATCH_TTL_HOURS = 4 

14MISSED_TTL_DAYS = 7 

15MAX_MISSED_PER_DAY = 3 

16PING_COOLDOWN_SECONDS = 30 

17MATCH_COOLDOWN_HOURS = 4 

18 

19 

20class ProximityService: 

21 

22 @staticmethod 

23 def haversine_distance(lat1, lon1, lat2, lon2): 

24 """Distance in meters between two GPS points.""" 

25 R = 6371000 # Earth radius in meters 

26 phi1 = math.radians(lat1) 

27 phi2 = math.radians(lat2) 

28 dphi = math.radians(lat2 - lat1) 

29 dlam = math.radians(lon2 - lon1) 

30 a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2 

31 return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) 

32 

33 @staticmethod 

34 def bounding_box(lat, lon, radius_m): 

35 """Bounding box for efficient pre-filter (min_lat, max_lat, min_lon, max_lon).""" 

36 dlat = radius_m / 111320.0 

37 dlon = radius_m / (111320.0 * math.cos(math.radians(lat))) 

38 return (lat - dlat, lat + dlat, lon - dlon, lon + dlon) 

39 

40 @staticmethod 

41 def update_location(db, user_id, lat, lon, accuracy, device_id=None): 

42 """Store location ping, detect nearby users, return match count. 

43 device_id — optional, for cross-device dedup (same user, multiple devices).""" 

44 from .models import LocationPing, User 

45 now = datetime.utcnow() 

46 

47 # Rate limit: 1 ping per 30 seconds (per device if provided) 

48 rate_q = db.query(LocationPing).filter( 

49 LocationPing.user_id == user_id, 

50 LocationPing.created_at > now - timedelta(seconds=PING_COOLDOWN_SECONDS) 

51 ) 

52 last_ping = rate_q.first() 

53 if last_ping: 

54 return {'nearby_count': ProximityService.get_nearby_count(db, user_id), 'rate_limited': True} 

55 

56 # Cross-device dedup: if same user+device has an active ping, update it 

57 if device_id: 

58 existing = db.query(LocationPing).filter( 

59 LocationPing.user_id == user_id, 

60 LocationPing.expires_at > now, 

61 ).first() 

62 if existing: 

63 existing.lat = lat 

64 existing.lon = lon 

65 existing.accuracy_m = accuracy or 0.0 

66 existing.created_at = now 

67 existing.expires_at = now + timedelta(hours=PING_TTL_HOURS) 

68 db.flush() 

69 matches_created = ProximityService._detect_proximity(db, user_id, lat, lon, now) 

70 return { 

71 'nearby_count': ProximityService.get_nearby_count(db, user_id), 

72 'new_matches': matches_created, 

73 'rate_limited': False, 

74 } 

75 

76 # Store ping 

77 ping = LocationPing( 

78 user_id=user_id, lat=lat, lon=lon, 

79 accuracy_m=accuracy or 0.0, 

80 expires_at=now + timedelta(hours=PING_TTL_HOURS) 

81 ) 

82 db.add(ping) 

83 

84 # Update user's last known location 

85 user = db.query(User).filter(User.id == user_id).first() 

86 if user: 

87 user.last_location_lat = lat 

88 user.last_location_lon = lon 

89 user.last_location_at = now 

90 

91 db.flush() 

92 

93 # Detect nearby users 

94 matches_created = ProximityService._detect_proximity(db, user_id, lat, lon, now) 

95 

96 return { 

97 'nearby_count': ProximityService.get_nearby_count(db, user_id), 

98 'new_matches': matches_created, 

99 'rate_limited': False, 

100 } 

101 

102 @staticmethod 

103 def _detect_proximity(db, user_id, lat, lon, now): 

104 """Find nearby users and create ProximityMatch records.""" 

105 from .models import LocationPing, ProximityMatch 

106 min_lat, max_lat, min_lon, max_lon = ProximityService.bounding_box(lat, lon, PROXIMITY_RADIUS_M) 

107 

108 # Bounding box pre-filter on recent pings (not expired, not self) 

109 candidates = db.query(LocationPing).filter( 

110 LocationPing.user_id != user_id, 

111 LocationPing.expires_at > now, 

112 LocationPing.lat >= min_lat, LocationPing.lat <= max_lat, 

113 LocationPing.lon >= min_lon, LocationPing.lon <= max_lon, 

114 ).all() 

115 

116 # Dedupe by user_id (keep most recent ping per user) 

117 user_pings = {} 

118 for p in candidates: 

119 if p.user_id not in user_pings or p.created_at > user_pings[p.user_id].created_at: 

120 user_pings[p.user_id] = p 

121 

122 matches_created = 0 

123 for other_id, ping in user_pings.items(): 

124 dist = ProximityService.haversine_distance(lat, lon, ping.lat, ping.lon) 

125 if dist > PROXIMITY_RADIUS_M: 

126 continue 

127 

128 # Canonical ordering: a_id < b_id 

129 a_id, b_id = (min(user_id, other_id), max(user_id, other_id)) 

130 

131 # Check cooldown: no pending/recent match between this pair 

132 recent = db.query(ProximityMatch).filter( 

133 ProximityMatch.user_a_id == a_id, 

134 ProximityMatch.user_b_id == b_id, 

135 ProximityMatch.created_at > now - timedelta(hours=MATCH_COOLDOWN_HOURS), 

136 ).first() 

137 if recent: 

138 continue 

139 

140 match = ProximityMatch( 

141 user_a_id=a_id, user_b_id=b_id, 

142 lat=(lat + ping.lat) / 2, # midpoint (never exposed to users) 

143 lon=(lon + ping.lon) / 2, 

144 distance_m=round(dist, 1), 

145 detected_at=now, 

146 expires_at=now + timedelta(hours=MATCH_TTL_HOURS), 

147 ) 

148 db.add(match) 

149 matches_created += 1 

150 

151 # Create notification for both users 

152 try: 

153 from .services import NotificationService 

154 for uid in [a_id, b_id]: 

155 NotificationService.create( 

156 db, user_id=uid, type='proximity_match', 

157 message='Someone is nearby! Check your encounters.', 

158 ) 

159 except Exception: 

160 pass # Notifications optional 

161 

162 return matches_created 

163 

164 @staticmethod 

165 def get_nearby_count(db, user_id): 

166 """Anonymous count of users with active pings within proximity.""" 

167 from .models import LocationPing, User 

168 user = db.query(User).filter(User.id == user_id).first() 

169 if not user or not user.last_location_lat: 

170 return 0 

171 

172 now = datetime.utcnow() 

173 min_lat, max_lat, min_lon, max_lon = ProximityService.bounding_box( 

174 user.last_location_lat, user.last_location_lon, PROXIMITY_RADIUS_M * 5) 

175 

176 pings = db.query(LocationPing.user_id).filter( 

177 LocationPing.user_id != user_id, 

178 LocationPing.expires_at > now, 

179 LocationPing.lat >= min_lat, LocationPing.lat <= max_lat, 

180 LocationPing.lon >= min_lon, LocationPing.lon <= max_lon, 

181 ).distinct().all() 

182 

183 count = 0 

184 for (uid,) in pings: 

185 # Get latest ping for this user 

186 p = db.query(LocationPing).filter( 

187 LocationPing.user_id == uid, 

188 LocationPing.expires_at > now 

189 ).order_by(LocationPing.created_at.desc()).first() 

190 if p: 

191 d = ProximityService.haversine_distance( 

192 user.last_location_lat, user.last_location_lon, p.lat, p.lon) 

193 if d <= PROXIMITY_RADIUS_M * 5: 

194 count += 1 

195 return count 

196 

197 @staticmethod 

198 def get_matches(db, user_id, status=None): 

199 """Get proximity matches for a user.""" 

200 from .models import ProximityMatch 

201 now = datetime.utcnow() 

202 q = db.query(ProximityMatch).filter( 

203 ProximityMatch.expires_at > now, 

204 ((ProximityMatch.user_a_id == user_id) | (ProximityMatch.user_b_id == user_id)) 

205 ) 

206 if status: 

207 q = q.filter(ProximityMatch.status == status) 

208 else: 

209 q = q.filter(ProximityMatch.status != 'expired') 

210 return [m.to_dict(viewer_id=user_id) for m in q.order_by(ProximityMatch.detected_at.desc()).all()] 

211 

212 @staticmethod 

213 def reveal_self(db, match_id, user_id): 

214 """Reveal yourself to a proximity match. Returns updated match.""" 

215 from .models import ProximityMatch, User 

216 now = datetime.utcnow() 

217 match = db.query(ProximityMatch).filter( 

218 ProximityMatch.id == match_id, 

219 ProximityMatch.expires_at > now, 

220 ).first() 

221 if not match: 

222 raise ValueError("Match not found or expired") 

223 

224 is_a = user_id == match.user_a_id 

225 is_b = user_id == match.user_b_id 

226 if not is_a and not is_b: 

227 raise ValueError("Not your match") 

228 

229 if match.status == 'matched': 

230 raise ValueError("Already matched") 

231 if match.status == 'expired': 

232 raise ValueError("Match expired") 

233 

234 if is_a: 

235 if match.a_revealed_at: 

236 raise ValueError("Already revealed") 

237 match.a_revealed_at = now 

238 if match.status == 'pending': 

239 match.status = 'revealed_a' 

240 elif match.status == 'revealed_b': 

241 match.status = 'matched' 

242 else: 

243 if match.b_revealed_at: 

244 raise ValueError("Already revealed") 

245 match.b_revealed_at = now 

246 if match.status == 'pending': 

247 match.status = 'revealed_b' 

248 elif match.status == 'revealed_a': 

249 match.status = 'matched' 

250 

251 # If both revealed, create an encounter 

252 if match.status == 'matched': 

253 try: 

254 from .encounter_service import EncounterService 

255 EncounterService.record_encounter( 

256 db, match.user_a_id, match.user_b_id, 

257 context_type='proximity', 

258 context_id=match.id, 

259 location_label=match.location_label or 'Nearby' 

260 ) 

261 # Award Pulse to both 

262 try: 

263 from .resonance_engine import ResonanceService 

264 for uid in [match.user_a_id, match.user_b_id]: 

265 ResonanceService.award_pulse(db, uid, 10, 'proximity_match', match.id, 

266 'Matched with someone nearby!') 

267 except Exception: 

268 pass 

269 except Exception as e: 

270 logger.warning(f"Failed to create encounter from proximity: {e}") 

271 

272 # Get user info for matched state 

273 result = match.to_dict(viewer_id=user_id) 

274 if match.status == 'matched': 

275 a = db.query(User).filter(User.id == match.user_a_id).first() 

276 b = db.query(User).filter(User.id == match.user_b_id).first() 

277 if a: 

278 result['user_a'] = {'id': a.id, 'username': a.username, 'display_name': a.display_name, 'avatar_url': a.avatar_url} 

279 if b: 

280 result['user_b'] = {'id': b.id, 'username': b.username, 'display_name': b.display_name, 'avatar_url': b.avatar_url} 

281 

282 return result 

283 

284 @staticmethod 

285 def create_missed_connection(db, user_id, lat, lon, location_name, description, was_at_iso): 

286 """Create a missed connection post. Rate limited to 3/day.""" 

287 from .models import MissedConnection 

288 now = datetime.utcnow() 

289 

290 # Parse was_at 

291 try: 

292 was_at = datetime.fromisoformat(was_at_iso.replace('Z', '+00:00').replace('+00:00', '')) 

293 except (ValueError, AttributeError): 

294 raise ValueError("Invalid was_at datetime format") 

295 

296 # Validate: must be within past 7 days 

297 if was_at > now: 

298 raise ValueError("was_at cannot be in the future") 

299 if (now - was_at).days > 7: 

300 raise ValueError("was_at must be within the past 7 days") 

301 

302 # Rate limit: 3 per day 

303 today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) 

304 count_today = db.query(MissedConnection).filter( 

305 MissedConnection.user_id == user_id, 

306 MissedConnection.created_at >= today_start, 

307 ).count() 

308 if count_today >= MAX_MISSED_PER_DAY: 

309 raise ValueError(f"Maximum {MAX_MISSED_PER_DAY} missed connections per day") 

310 

311 # Validate inputs 

312 if not location_name or len(location_name.strip()) < 3: 

313 raise ValueError("Location name must be at least 3 characters") 

314 if len(location_name) > 200: 

315 raise ValueError("Location name must be under 200 characters") 

316 if description and len(description) > 500: 

317 raise ValueError("Description must be under 500 characters") 

318 

319 mc = MissedConnection( 

320 user_id=user_id, lat=lat, lon=lon, 

321 location_name=location_name.strip(), 

322 description=(description or '').strip(), 

323 was_at=was_at, 

324 expires_at=now + timedelta(days=MISSED_TTL_DAYS), 

325 ) 

326 db.add(mc) 

327 db.flush() 

328 return mc.to_dict() 

329 

330 @staticmethod 

331 def search_missed_connections(db, lat, lon, radius_m, limit=20, offset=0, exclude_user_id=None, sort='recent'): 

332 """Search missed connections within radius. Returns list + has_more.""" 

333 from .models import MissedConnection 

334 now = datetime.utcnow() 

335 min_lat, max_lat, min_lon, max_lon = ProximityService.bounding_box(lat, lon, radius_m) 

336 

337 q = db.query(MissedConnection).filter( 

338 MissedConnection.is_active == True, 

339 MissedConnection.expires_at > now, 

340 MissedConnection.lat >= min_lat, MissedConnection.lat <= max_lat, 

341 MissedConnection.lon >= min_lon, MissedConnection.lon <= max_lon, 

342 ) 

343 if exclude_user_id: 

344 q = q.filter(MissedConnection.user_id != exclude_user_id) 

345 

346 # Get all candidates for haversine filtering 

347 candidates = q.all() 

348 

349 # Post-filter by exact distance 

350 results = [] 

351 for mc in candidates: 

352 dist = ProximityService.haversine_distance(lat, lon, mc.lat, mc.lon) 

353 if dist <= radius_m: 

354 d = mc.to_dict(viewer_lat=lat, viewer_lon=lon) 

355 d['_distance'] = dist 

356 results.append(d) 

357 

358 # Sort 

359 if sort == 'nearest': 

360 results.sort(key=lambda x: x['_distance']) 

361 elif sort == 'responses': 

362 results.sort(key=lambda x: x.get('response_count', 0), reverse=True) 

363 else: # recent 

364 results.sort(key=lambda x: x.get('created_at', ''), reverse=True) 

365 

366 # Paginate 

367 total = len(results) 

368 page = results[offset:offset + limit] 

369 for r in page: 

370 r.pop('_distance', None) 

371 

372 return {'data': page, 'meta': {'total': total, 'has_more': offset + limit < total}} 

373 

374 @staticmethod 

375 def get_my_missed_connections(db, user_id, limit=20, offset=0): 

376 """Get user's own missed connections.""" 

377 from .models import MissedConnection 

378 q = db.query(MissedConnection).filter( 

379 MissedConnection.user_id == user_id, 

380 ).order_by(MissedConnection.created_at.desc()) 

381 total = q.count() 

382 items = q.offset(offset).limit(limit).all() 

383 return { 

384 'data': [mc.to_dict() for mc in items], 

385 'meta': {'total': total, 'has_more': offset + limit < total} 

386 } 

387 

388 @staticmethod 

389 def get_missed_with_responses(db, missed_id, viewer_id=None): 

390 """Get missed connection with all responses.""" 

391 from .models import MissedConnection, MissedConnectionResponse, User 

392 mc = db.query(MissedConnection).filter(MissedConnection.id == missed_id).first() 

393 if not mc: 

394 raise ValueError("Missed connection not found") 

395 

396 d = mc.to_dict() 

397 # Get poster info 

398 poster = db.query(User).filter(User.id == mc.user_id).first() 

399 if poster: 

400 d['user'] = {'id': poster.id, 'username': poster.username, 

401 'display_name': poster.display_name, 'avatar_url': poster.avatar_url} 

402 

403 # Get all responses with responder info 

404 responses = db.query(MissedConnectionResponse).filter( 

405 MissedConnectionResponse.missed_connection_id == missed_id, 

406 ).order_by(MissedConnectionResponse.created_at.desc()).all() 

407 

408 resp_list = [] 

409 for r in responses: 

410 rd = r.to_dict() 

411 responder = db.query(User).filter(User.id == r.responder_id).first() 

412 if responder: 

413 rd['responder'] = {'id': responder.id, 'username': responder.username, 

414 'display_name': responder.display_name, 'avatar_url': responder.avatar_url} 

415 resp_list.append(rd) 

416 

417 d['responses'] = resp_list 

418 d['is_owner'] = viewer_id == mc.user_id if viewer_id else False 

419 return d 

420 

421 @staticmethod 

422 def respond_to_missed(db, missed_id, user_id, message): 

423 """Add 'I was there too' response.""" 

424 from .models import MissedConnection, MissedConnectionResponse 

425 mc = db.query(MissedConnection).filter( 

426 MissedConnection.id == missed_id, 

427 MissedConnection.is_active == True, 

428 ).first() 

429 if not mc: 

430 raise ValueError("Missed connection not found or expired") 

431 if mc.user_id == user_id: 

432 raise ValueError("Cannot respond to your own missed connection") 

433 

434 # Check if already responded 

435 existing = db.query(MissedConnectionResponse).filter( 

436 MissedConnectionResponse.missed_connection_id == missed_id, 

437 MissedConnectionResponse.responder_id == user_id, 

438 ).first() 

439 if existing: 

440 raise ValueError("Already responded to this missed connection") 

441 

442 if message and len(message) > 300: 

443 raise ValueError("Message must be under 300 characters") 

444 

445 resp = MissedConnectionResponse( 

446 missed_connection_id=missed_id, 

447 responder_id=user_id, 

448 message=(message or '').strip(), 

449 ) 

450 db.add(resp) 

451 mc.response_count = (mc.response_count or 0) + 1 

452 db.flush() 

453 return resp.to_dict() 

454 

455 @staticmethod 

456 def accept_missed_response(db, missed_id, response_id, user_id): 

457 """Owner accepts a response -> creates encounter.""" 

458 from .models import MissedConnection, MissedConnectionResponse 

459 mc = db.query(MissedConnection).filter(MissedConnection.id == missed_id).first() 

460 if not mc: 

461 raise ValueError("Missed connection not found") 

462 if mc.user_id != user_id: 

463 raise ValueError("Only the poster can accept responses") 

464 

465 resp = db.query(MissedConnectionResponse).filter( 

466 MissedConnectionResponse.id == response_id, 

467 MissedConnectionResponse.missed_connection_id == missed_id, 

468 ).first() 

469 if not resp: 

470 raise ValueError("Response not found") 

471 if resp.status != 'pending': 

472 raise ValueError(f"Response already {resp.status}") 

473 

474 resp.status = 'accepted' 

475 

476 # Create encounter 

477 try: 

478 from .encounter_service import EncounterService 

479 EncounterService.record_encounter( 

480 db, mc.user_id, resp.responder_id, 

481 context_type='missed_connection', 

482 context_id=missed_id, 

483 location_label=mc.location_name 

484 ) 

485 except Exception as e: 

486 logger.warning(f"Failed to create encounter from missed connection: {e}") 

487 

488 # Award Pulse 

489 try: 

490 from .resonance_engine import ResonanceService 

491 for uid in [mc.user_id, resp.responder_id]: 

492 ResonanceService.award_pulse(db, uid, 5, 'missed_connection', missed_id, 

493 f'Connected at {mc.location_name}!') 

494 except Exception: 

495 pass 

496 

497 return resp.to_dict() 

498 

499 @staticmethod 

500 def delete_missed_connection(db, missed_id, user_id): 

501 """Remove own missed connection.""" 

502 from .models import MissedConnection 

503 mc = db.query(MissedConnection).filter(MissedConnection.id == missed_id).first() 

504 if not mc: 

505 raise ValueError("Not found") 

506 if mc.user_id != user_id: 

507 raise ValueError("Not your missed connection") 

508 mc.is_active = False 

509 return {'deleted': True} 

510 

511 @staticmethod 

512 def auto_suggest_locations(db, lat, lon, radius_m=5000): 

513 """Return popular location names from nearby recent missed connections.""" 

514 from .models import MissedConnection 

515 from sqlalchemy import func as sqlfunc 

516 now = datetime.utcnow() 

517 min_lat, max_lat, min_lon, max_lon = ProximityService.bounding_box(lat, lon, radius_m) 

518 

519 results = db.query( 

520 MissedConnection.location_name, 

521 sqlfunc.count(MissedConnection.id).label('count') 

522 ).filter( 

523 MissedConnection.is_active == True, 

524 MissedConnection.expires_at > now, 

525 MissedConnection.lat >= min_lat, MissedConnection.lat <= max_lat, 

526 MissedConnection.lon >= min_lon, MissedConnection.lon <= max_lon, 

527 ).group_by(MissedConnection.location_name).order_by( 

528 sqlfunc.count(MissedConnection.id).desc() 

529 ).limit(10).all() 

530 

531 return [{'name': name, 'count': count} for name, count in results] 

532 

533 @staticmethod 

534 def get_location_settings(db, user_id): 

535 """Get user's location sharing settings.""" 

536 from .models import User 

537 user = db.query(User).filter(User.id == user_id).first() 

538 if not user: 

539 raise ValueError("User not found") 

540 return { 

541 'location_sharing_enabled': user.location_sharing_enabled or False, 

542 'has_location': user.last_location_lat is not None, 

543 } 

544 

545 @staticmethod 

546 def update_location_settings(db, user_id, enabled): 

547 """Toggle location sharing.""" 

548 from .models import User 

549 user = db.query(User).filter(User.id == user_id).first() 

550 if not user: 

551 raise ValueError("User not found") 

552 user.location_sharing_enabled = bool(enabled) 

553 if not enabled: 

554 user.last_location_lat = None 

555 user.last_location_lon = None 

556 user.last_location_at = None 

557 return {'location_sharing_enabled': user.location_sharing_enabled} 

558 

559 @staticmethod 

560 def cleanup_expired(db): 

561 """Delete expired pings, deactivate expired missed connections, expire pending matches.""" 

562 from .models import LocationPing, ProximityMatch, MissedConnection 

563 now = datetime.utcnow() 

564 

565 # Delete expired pings 

566 deleted_pings = db.query(LocationPing).filter(LocationPing.expires_at <= now).delete() 

567 

568 # Expire matches 

569 expired_matches = db.query(ProximityMatch).filter( 

570 ProximityMatch.expires_at <= now, 

571 ProximityMatch.status.in_(['pending', 'revealed_a', 'revealed_b']) 

572 ).update({'status': 'expired'}, synchronize_session='fetch') 

573 

574 # Deactivate expired missed connections 

575 expired_missed = db.query(MissedConnection).filter( 

576 MissedConnection.expires_at <= now, 

577 MissedConnection.is_active == True, 

578 ).update({'is_active': False}, synchronize_session='fetch') 

579 

580 logger.info(f"Proximity cleanup: {deleted_pings} pings, {expired_matches} matches, {expired_missed} missed connections") 

581 return {'pings': deleted_pings, 'matches': expired_matches, 'missed': expired_missed}