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
« 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
9logger = logging.getLogger('hevolve_social')
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
20class ProximityService:
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))
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)
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()
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}
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 }
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)
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
91 db.flush()
93 # Detect nearby users
94 matches_created = ProximityService._detect_proximity(db, user_id, lat, lon, now)
96 return {
97 'nearby_count': ProximityService.get_nearby_count(db, user_id),
98 'new_matches': matches_created,
99 'rate_limited': False,
100 }
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)
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()
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
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
128 # Canonical ordering: a_id < b_id
129 a_id, b_id = (min(user_id, other_id), max(user_id, other_id))
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
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
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
162 return matches_created
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
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)
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()
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
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()]
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")
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")
229 if match.status == 'matched':
230 raise ValueError("Already matched")
231 if match.status == 'expired':
232 raise ValueError("Match expired")
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'
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}")
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}
282 return result
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()
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")
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")
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")
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")
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()
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)
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)
346 # Get all candidates for haversine filtering
347 candidates = q.all()
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)
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)
366 # Paginate
367 total = len(results)
368 page = results[offset:offset + limit]
369 for r in page:
370 r.pop('_distance', None)
372 return {'data': page, 'meta': {'total': total, 'has_more': offset + limit < total}}
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 }
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")
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}
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()
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)
417 d['responses'] = resp_list
418 d['is_owner'] = viewer_id == mc.user_id if viewer_id else False
419 return d
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")
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")
442 if message and len(message) > 300:
443 raise ValueError("Message must be under 300 characters")
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()
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")
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}")
474 resp.status = 'accepted'
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}")
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
497 return resp.to_dict()
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}
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)
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()
531 return [{'name': name, 'count': count} for name, count in results]
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 }
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}
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()
565 # Delete expired pings
566 deleted_pings = db.query(LocationPing).filter(LocationPing.expires_at <= now).delete()
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')
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')
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}