Coverage for integrations / social / friend_service.py: 0.0%
184 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 — Friendship + Block service.
4Phase 7c.1. Plan reference: sunny-gliding-eich.md, Part E.8 + Part R.5.
6Coexists with the existing one-direction `FollowService` (services.py).
7This service adds a SYMMETRIC, STATEFUL relationship layer:
9 pending ─accept─→ active ─block─→ blocked
10 │ │ ↑
11 └─reject──→ rejected │
12 └─cancel──→ deleted │
13 active ─block (anytime)┘
14 any ─unblock──→ active or deleted
16Existing Follow rows are untouched. On accept(), reciprocal Follow rows
17are auto-created so legacy code reading the follow graph (feed
18ranking, recommendations) continues to work without change.
20Transport: P2P-first via NotificationService.create which already
21publishes on the existing social.{user_id} WAMP topic and fans out
22through MessageBus (plan Part R). When two users become friends the
23PeerLink trust ratchet upgrades automatically (link_manager.upgrade_peer)
24the next time both come online.
25"""
27from __future__ import annotations
29import logging
30import uuid
31from typing import List, Optional, Tuple
33logger = logging.getLogger('hevolve_social')
36def _sorted_pair(a: str, b: str) -> Tuple[str, str]:
37 """Friendship rows are stored with (min, max) of the pair so each
38 relationship has exactly one canonical row regardless of who
39 initiated."""
40 return (a, b) if a < b else (b, a)
43def _is_blocked_either_way(db, x: str, y: str) -> bool:
44 """True if x blocks y OR y blocks x. Used to gate friend requests."""
45 from sqlalchemy import text
46 try:
47 result = db.execute(text(
48 "SELECT 1 FROM blocks "
49 "WHERE (blocker_id = :x AND blocked_id = :y) "
50 "OR (blocker_id = :y AND blocked_id = :x) LIMIT 1"),
51 {'x': x, 'y': y}
52 ).fetchone()
53 return result is not None
54 except Exception:
55 return False
58class FriendError(ValueError):
59 pass
62class FriendService:
63 """All public methods are static. Each takes an explicit `db` so
64 they can be called from any context without coupling to Flask `g`.
65 """
67 # ── Sending / responding ─────────────────────────────────────
69 @staticmethod
70 def send_request(db, from_user_id: str, to_user_id: str,
71 tenant_id: Optional[str] = None) -> dict:
72 """Create a pending Friendship row and notify the recipient.
74 Idempotent: if a row already exists, returns its current
75 state without creating a duplicate. Refuses if either party
76 has blocked the other (silently — no leak whether the block
77 is on which side).
78 """
79 from sqlalchemy import text
80 from .services import NotificationService
82 if from_user_id == to_user_id:
83 raise FriendError("cannot friend yourself")
84 if _is_blocked_either_way(db, from_user_id, to_user_id):
85 raise FriendError("cannot send request")
87 a, b = _sorted_pair(from_user_id, to_user_id)
88 # Look up existing row.
89 existing = db.execute(text(
90 "SELECT id, status, initiator_id FROM friendships "
91 "WHERE user_a_id = :a AND user_b_id = :b"),
92 {'a': a, 'b': b}
93 ).fetchone()
94 if existing:
95 fid, status, initiator = existing[0], existing[1], existing[2]
96 if status == 'active':
97 return {'id': fid, 'status': 'active'}
98 if status == 'pending':
99 # Idempotent — same initiator re-sending = no change;
100 # OTHER user re-sending = auto-accept.
101 if initiator == from_user_id:
102 return {'id': fid, 'status': 'pending'}
103 # Auto-accept: the other side already requested.
104 return FriendService._accept_internal(
105 db, fid, accepting_user_id=from_user_id)
106 if status == 'rejected':
107 # Reset to pending — give them another shot.
108 db.execute(text(
109 "UPDATE friendships SET status='pending', "
110 "initiator_id=:i, created_at=CURRENT_TIMESTAMP, "
111 "accepted_at=NULL "
112 "WHERE id=:fid"),
113 {'i': from_user_id, 'fid': fid})
114 db.commit()
115 FriendService._notify_request(db, fid, from_user_id, to_user_id)
116 return {'id': fid, 'status': 'pending'}
117 if status == 'blocked':
118 raise FriendError("cannot send request")
120 # No prior row → insert pending.
121 fid = str(uuid.uuid4())
122 db.execute(text(
123 "INSERT INTO friendships "
124 "(id, tenant_id, user_a_id, user_b_id, status, initiator_id, "
125 " created_at) "
126 "VALUES "
127 "(:id, :tid, :a, :b, 'pending', :i, CURRENT_TIMESTAMP)"),
128 {'id': fid, 'tid': tenant_id, 'a': a, 'b': b,
129 'i': from_user_id})
130 db.commit()
131 FriendService._notify_request(db, fid, from_user_id, to_user_id)
132 return {'id': fid, 'status': 'pending'}
134 @staticmethod
135 def accept(db, friendship_id: str, accepting_user_id: str) -> dict:
136 return FriendService._accept_internal(db, friendship_id, accepting_user_id)
138 @staticmethod
139 def _accept_internal(db, friendship_id: str,
140 accepting_user_id: str) -> dict:
141 from sqlalchemy import text
142 from .services import NotificationService
144 row = db.execute(text(
145 "SELECT user_a_id, user_b_id, status, initiator_id "
146 "FROM friendships WHERE id = :id"),
147 {'id': friendship_id}
148 ).fetchone()
149 if row is None:
150 raise FriendError("not found")
151 a, b, status, initiator = row[0], row[1], row[2], row[3]
152 if accepting_user_id not in (a, b):
153 raise FriendError("not a participant")
154 if accepting_user_id == initiator and status == 'pending':
155 raise FriendError("initiator cannot self-accept")
156 if status == 'active':
157 return {'id': friendship_id, 'status': 'active'}
158 if status not in ('pending', 'rejected'):
159 raise FriendError(f"cannot accept from status={status}")
161 db.execute(text(
162 "UPDATE friendships SET status='active', "
163 "accepted_at=CURRENT_TIMESTAMP WHERE id = :id"),
164 {'id': friendship_id})
166 # Auto-create reciprocal Follow rows so downstream code
167 # reading the follow graph (feed ranking, recommendations)
168 # treats friends as mutual followers without needing a
169 # parallel code path.
170 #
171 # Idempotent. We dispatch on the live dialect rather than
172 # try/except — on Postgres a failed INSERT leaves the tx in
173 # pending-rollback, so the second try would fail with
174 # "current transaction is aborted" instead of silently
175 # ignoring the duplicate.
176 dialect = db.bind.dialect.name if db.bind is not None else 'sqlite'
177 if dialect == 'sqlite':
178 stmt = ("INSERT OR IGNORE INTO follows "
179 "(id, follower_id, following_id, created_at) "
180 "VALUES (:id, :f, :t, CURRENT_TIMESTAMP)")
181 else:
182 # Postgres + MySQL 8+ both accept ON CONFLICT DO NOTHING.
183 stmt = ("INSERT INTO follows "
184 "(id, follower_id, following_id, created_at) "
185 "VALUES (:id, :f, :t, CURRENT_TIMESTAMP) "
186 "ON CONFLICT DO NOTHING")
187 for follower, followed in ((a, b), (b, a)):
188 db.execute(text(stmt), {'id': str(uuid.uuid4()),
189 'f': follower, 't': followed})
191 db.commit()
193 # Notify the initiator that their request was accepted.
194 try:
195 NotificationService.create(
196 db, user_id=initiator, type='friend_accepted',
197 source_user_id=accepting_user_id,
198 target_type='user', target_id=accepting_user_id,
199 message="Your friend request was accepted")
200 except Exception as e:
201 logger.warning("FriendService.accept: notify failed: %s", e)
203 return {'id': friendship_id, 'status': 'active'}
205 @staticmethod
206 def reject(db, friendship_id: str, rejecting_user_id: str) -> dict:
207 from sqlalchemy import text
208 row = db.execute(text(
209 "SELECT user_a_id, user_b_id, status, initiator_id "
210 "FROM friendships WHERE id = :id"),
211 {'id': friendship_id}
212 ).fetchone()
213 if row is None:
214 raise FriendError("not found")
215 a, b, status, initiator = row[0], row[1], row[2], row[3]
216 if rejecting_user_id not in (a, b):
217 raise FriendError("not a participant")
218 if rejecting_user_id == initiator:
219 raise FriendError("initiator cannot reject — use cancel")
220 if status not in ('pending',):
221 raise FriendError(f"cannot reject from status={status}")
222 db.execute(text(
223 "UPDATE friendships SET status='rejected' WHERE id = :id"),
224 {'id': friendship_id})
225 db.commit()
226 return {'id': friendship_id, 'status': 'rejected'}
228 @staticmethod
229 def cancel(db, friendship_id: str, canceller_id: str) -> dict:
230 """Initiator withdraws their own pending request — row deleted."""
231 from sqlalchemy import text
232 row = db.execute(text(
233 "SELECT initiator_id, status FROM friendships WHERE id = :id"),
234 {'id': friendship_id}
235 ).fetchone()
236 if row is None:
237 raise FriendError("not found")
238 initiator, status = row[0], row[1]
239 if canceller_id != initiator:
240 raise FriendError("only the initiator can cancel")
241 if status != 'pending':
242 raise FriendError("only pending requests can be cancelled")
243 db.execute(text("DELETE FROM friendships WHERE id = :id"),
244 {'id': friendship_id})
245 db.commit()
246 return {'id': friendship_id, 'status': 'cancelled'}
248 @staticmethod
249 def unfriend(db, requester_id: str, other_id: str) -> dict:
250 """Remove an active friendship without blocking.
252 Either participant can unfriend. The reciprocal Follow rows
253 auto-created on accept() are NOT removed — unfriend is the
254 symmetric inverse of accept(), follows are independent and
255 the user can unfollow separately if they want. This matches
256 Twitter/Instagram semantics: "we're no longer friends" does
257 not silently remove follows the user may have set up
258 themselves.
259 """
260 from sqlalchemy import text
261 if requester_id == other_id:
262 raise FriendError("cannot unfriend yourself")
263 a, b = _sorted_pair(requester_id, other_id)
264 row = db.execute(text(
265 "SELECT id, status FROM friendships "
266 "WHERE user_a_id = :a AND user_b_id = :b"),
267 {'a': a, 'b': b}
268 ).fetchone()
269 if row is None:
270 raise FriendError("not friends")
271 fid, status = row[0], row[1]
272 if status != 'active':
273 raise FriendError(f"cannot unfriend from status={status}")
274 db.execute(text("DELETE FROM friendships WHERE id = :id"),
275 {'id': fid})
276 db.commit()
277 return {'id': fid, 'status': 'unfriended'}
279 # ── Block ────────────────────────────────────────────────────
281 @staticmethod
282 def block(db, blocker_id: str, blocked_id: str,
283 reason: Optional[str] = None,
284 tenant_id: Optional[str] = None) -> dict:
285 """Block a user. Tears down any active friendship and
286 triggers the PeerLink trust-ratchet teardown via the
287 link_manager (plan Part R.5). Idempotent."""
288 from sqlalchemy import text
289 if blocker_id == blocked_id:
290 raise FriendError("cannot block yourself")
292 # Insert/upsert block row.
293 bid = str(uuid.uuid4())
294 existing = db.execute(text(
295 "SELECT id FROM blocks "
296 "WHERE blocker_id = :a AND blocked_id = :b"),
297 {'a': blocker_id, 'b': blocked_id}
298 ).fetchone()
299 if existing is None:
300 db.execute(text(
301 "INSERT INTO blocks "
302 "(id, tenant_id, blocker_id, blocked_id, reason, "
303 " created_at) "
304 "VALUES (:id, :tid, :a, :b, :r, CURRENT_TIMESTAMP)"),
305 {'id': bid, 'tid': tenant_id, 'a': blocker_id,
306 'b': blocked_id, 'r': reason})
308 # Mark any existing friendship as blocked.
309 a, b = _sorted_pair(blocker_id, blocked_id)
310 db.execute(text(
311 "UPDATE friendships SET status='blocked', "
312 "blocked_at=CURRENT_TIMESTAMP "
313 "WHERE user_a_id = :a AND user_b_id = :b"),
314 {'a': a, 'b': b})
315 db.commit()
317 # Tear down PeerLink trust (best-effort — module may be
318 # unavailable in a test environment).
319 try:
320 from core.peer_link.link_manager import get_link_manager
321 mgr = get_link_manager()
322 if mgr.has_link(blocked_id):
323 mgr.close_link(blocked_id)
324 except Exception:
325 pass
327 return {'id': existing[0] if existing else bid, 'status': 'blocked'}
329 @staticmethod
330 def unblock(db, blocker_id: str, blocked_id: str) -> dict:
331 """Remove a block row. Friendship row stays in 'blocked'
332 state until the blocker explicitly re-friends — unblocking
333 does NOT auto-restore an old friendship."""
334 from sqlalchemy import text
335 result = db.execute(text(
336 "DELETE FROM blocks "
337 "WHERE blocker_id = :a AND blocked_id = :b"),
338 {'a': blocker_id, 'b': blocked_id})
339 db.commit()
340 return {'unblocked': True}
342 # ── Read paths ───────────────────────────────────────────────
344 @staticmethod
345 def list_friends(db, user_id: str,
346 status: str = 'active') -> List[dict]:
347 """Return friends in the given state.
349 Default 'active' returns the canonical "my friends" list.
350 Pass status='pending' to get incoming + outgoing pending
351 requests. status='all' for everything.
352 """
353 from sqlalchemy import text
354 from .models import User
356 if status == 'all':
357 where = ""
358 params = {'uid': user_id}
359 else:
360 where = "AND status = :st"
361 params = {'uid': user_id, 'st': status}
363 rows = db.execute(text(
364 "SELECT id, user_a_id, user_b_id, status, initiator_id, "
365 " created_at, accepted_at "
366 "FROM friendships "
367 "WHERE (user_a_id = :uid OR user_b_id = :uid) " + where),
368 params
369 ).fetchall()
371 out = []
372 for row in rows:
373 other_id = row[2] if row[1] == user_id else row[1]
374 other = db.query(User).filter(User.id == other_id).first()
375 if not other:
376 continue
377 out.append({
378 'friendship_id': row[0],
379 'status': row[3],
380 'initiator_id': row[4],
381 'is_initiator': row[4] == user_id,
382 'created_at': str(row[5]) if row[5] else None,
383 'accepted_at': str(row[6]) if row[6] else None,
384 'other_user': {
385 'id': other.id,
386 'username': other.username,
387 'display_name': getattr(other, 'display_name', None) or other.username,
388 'avatar_url': getattr(other, 'avatar_url', None),
389 'agent_kind': 'agent' if (getattr(other, 'user_type', '') == 'agent') else 'human',
390 },
391 })
392 return out
394 @staticmethod
395 def list_blocks(db, user_id: str) -> List[dict]:
396 from sqlalchemy import text
397 from .models import User
398 rows = db.execute(text(
399 "SELECT b.id, b.blocked_id, b.reason, b.created_at "
400 "FROM blocks b WHERE b.blocker_id = :uid"),
401 {'uid': user_id}
402 ).fetchall()
403 out = []
404 for row in rows:
405 blocked = db.query(User).filter(User.id == row[1]).first()
406 if not blocked:
407 continue
408 out.append({
409 'block_id': row[0],
410 'reason': row[2],
411 'created_at': str(row[3]) if row[3] else None,
412 'blocked_user': {
413 'id': blocked.id,
414 'username': blocked.username,
415 },
416 })
417 return out
419 @staticmethod
420 def is_friend(db, a: str, b: str) -> bool:
421 """Single-row lookup used by privacy.py + autocomplete ranking."""
422 from sqlalchemy import text
423 ua, ub = _sorted_pair(a, b)
424 result = db.execute(text(
425 "SELECT 1 FROM friendships "
426 "WHERE user_a_id = :a AND user_b_id = :b "
427 "AND status = 'active' LIMIT 1"),
428 {'a': ua, 'b': ub}
429 ).fetchone()
430 return result is not None
432 # ── Internal helpers ─────────────────────────────────────────
434 @staticmethod
435 def _notify_request(db, friendship_id: str, from_user_id: str,
436 to_user_id: str):
437 try:
438 from .services import NotificationService
439 NotificationService.create(
440 db, user_id=to_user_id, type='friend_request',
441 source_user_id=from_user_id,
442 target_type='friendship', target_id=friendship_id,
443 message="You have a new friend request")
444 except Exception as e:
445 logger.warning("FriendService: notify_request failed: %s", e)