Coverage for integrations / social / reaction_service.py: 0.0%
116 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 — Emoji reactions on posts / comments / messages.
4Phase 7c.4. Plan reference: sunny-gliding-eich.md, Part E.6.
6Coexists with the existing binary VoteService — votes are aggregate
7karma, reactions are emoji. Both can be applied to the same source
8without conflict; the data flows separately.
10Polymorphic by (source_kind, source_id):
11 source_kind ∈ {'post', 'comment', 'message'}.
12 source_id is the primary key of the source row.
14Toggle semantics:
15 - First call by a user with a given emoji on a given source → INSERT.
16 - Second call by the same user with the same emoji → DELETE.
17 - Other users' reactions are unaffected.
18 - The UNIQUE INDEX (source_kind, source_id, user_id, emoji) guards
19 against accidental duplicates from concurrent toggles.
21Allowed emoji set is a small whitelist today; tenants can override
22via per-tenant settings (Phase 8) — until then everyone shares the
23same list to keep the UX coherent.
25Block check: a user reacting to content authored by someone they've
26blocked (or who blocked them) is silently no-op'd. This matches the
27mention/notification pattern from earlier phases.
29Transport:
30 - INSERT/DELETE go through the regular DB session; no separate
31 fan-out leg today (reactions are best surfaced via /sync deltas
32 and a per-source aggregate fetch).
33 - When `reactions` flag is on AND the source has subscribers, the
34 aggregate count is published over the existing post/comment/message
35 realtime topic via NotificationService — no privileged path.
36"""
38from __future__ import annotations
40import logging
41import uuid
42from typing import List, Optional, Tuple
44logger = logging.getLogger('hevolve_social')
47_VALID_SOURCE_KINDS = ('post', 'comment', 'message')
49# Whitelist matches plan Part E.6. Per-tenant override comes in Phase 8.
50ALLOWED_EMOJI = frozenset([
51 '👍', '❤️', '🔥', '😂', '😢', '😮', '🎉', '👎', '🚀',
52])
54_MAX_USERS_IN_PREVIEW = 5
57class ReactionError(ValueError):
58 pass
61def _is_blocked_either_way(db, x: str, y: str) -> bool:
62 try:
63 from sqlalchemy import text
64 result = db.execute(text(
65 "SELECT 1 FROM blocks "
66 "WHERE (blocker_id = :x AND blocked_id = :y) "
67 "OR (blocker_id = :y AND blocked_id = :x) LIMIT 1"),
68 {'x': x, 'y': y}
69 ).fetchone()
70 return result is not None
71 except Exception:
72 return False
75def _author_of(db, source_kind: str, source_id: str,
76 tenant_id: Optional[str] = None) -> Optional[str]:
77 """Look up the author_id of a post/comment/message.
79 Used for the block check — we don't want a user reacting to
80 content from someone they've blocked (or who blocked them) and
81 surfacing the reaction back.
83 Tenant gate (reviewer C-NEW-2): when `tenant_id` is set, the
84 lookup additionally filters to rows where the source's tenant
85 matches OR is NULL (legacy pass-through, same semantics as
86 sync_service._tenant_predicate). Without this, an attacker who
87 leaks a `source_id` from a different tenant could trick the
88 reaction service into letting them react across tenants —
89 counts would then leak across the boundary in `list_for`.
90 """
91 from sqlalchemy import text
92 table = {'post': 'posts', 'comment': 'comments',
93 'message': 'messages'}.get(source_kind)
94 if not table:
95 return None
96 sql = f"SELECT author_id FROM {table} WHERE id = :sid"
97 params = {'sid': source_id}
98 if tenant_id:
99 sql += " AND (tenant_id = :tid OR tenant_id IS NULL)"
100 params['tid'] = tenant_id
101 try:
102 row = db.execute(text(sql), params).fetchone()
103 return row[0] if row else None
104 except Exception:
105 return None
108def _public_count_for(db, source_kind: str, source_id: str, emoji: str,
109 tenant_id: Optional[str] = None) -> int:
110 """Aggregate count for a single (source, emoji) — used so a
111 silent-noop response carries the SAME shape a successful response
112 would, so an attacker can't infer block state by comparing
113 `count: 0` vs `count: N` (reviewer C-NEW-1).
114 """
115 from sqlalchemy import text
116 sql = ("SELECT COUNT(*) FROM reactions "
117 "WHERE source_kind = :sk AND source_id = :sid AND emoji = :em")
118 params = {'sk': source_kind, 'sid': source_id, 'em': emoji}
119 if tenant_id:
120 sql += " AND (tenant_id = :tid OR tenant_id IS NULL)"
121 params['tid'] = tenant_id
122 try:
123 row = db.execute(text(sql), params).fetchone()
124 return int(row[0]) if row else 0
125 except Exception:
126 return 0
129class ReactionService:
130 """All public methods are static. Each takes an explicit `db` so
131 they can be called from any context without coupling to Flask `g`.
132 """
134 @staticmethod
135 def toggle(db, source_kind: str, source_id: str, user_id: str,
136 emoji: str,
137 tenant_id: Optional[str] = None) -> dict:
138 """Toggle the (user_id, emoji) reaction on the source.
140 Returns:
141 { 'action': 'added'|'removed', 'emoji': str,
142 'count': int, 'me_reacted': bool }
144 Refuses if:
145 - source_kind not in valid set
146 - emoji not in ALLOWED_EMOJI
147 - source row doesn't exist
148 - either party has blocked the other
149 """
150 from sqlalchemy import text
152 if source_kind not in _VALID_SOURCE_KINDS:
153 raise ReactionError(f"invalid source_kind: {source_kind}")
154 if emoji not in ALLOWED_EMOJI:
155 raise ReactionError(f"emoji not allowed: {emoji}")
157 # Confirm the source exists + capture author for block check.
158 # Tenant-scoped lookup (C-NEW-2) so a cross-tenant source_id
159 # never proceeds. When tenant_id is None (flat/regional)
160 # the lookup is unscoped — same pass-through semantics as
161 # sync_service.
162 author_id = _author_of(db, source_kind, source_id, tenant_id)
163 if author_id is None:
164 raise ReactionError(f"{source_kind} not found")
165 # Reactor is allowed to react to their OWN content.
166 if author_id != user_id and _is_blocked_either_way(
167 db, user_id, author_id):
168 # Silent no-op — but return the SAME shape (real public
169 # count for that emoji) so an attacker can't compare to
170 # other endpoints and infer block state (C-NEW-1).
171 return {
172 'action': 'noop', 'emoji': emoji,
173 'count': _public_count_for(
174 db, source_kind, source_id, emoji, tenant_id),
175 'me_reacted': False,
176 }
178 # Check if reaction already exists.
179 existing = db.execute(text(
180 "SELECT id FROM reactions "
181 "WHERE source_kind = :sk AND source_id = :sid "
182 "AND user_id = :uid AND emoji = :em LIMIT 1"),
183 {'sk': source_kind, 'sid': source_id,
184 'uid': user_id, 'em': emoji}
185 ).fetchone()
187 if existing:
188 # Toggle off — remove it.
189 db.execute(text(
190 "DELETE FROM reactions WHERE id = :rid"),
191 {'rid': existing[0]})
192 db.commit()
193 action = 'removed'
194 me_reacted = False
195 else:
196 # Toggle on — insert (UNIQUE INDEX guards against race).
197 try:
198 db.execute(text(
199 "INSERT INTO reactions "
200 "(id, tenant_id, source_kind, source_id, user_id, "
201 " emoji, created_at) "
202 "VALUES (:id, :tid, :sk, :sid, :uid, :em, "
203 " CURRENT_TIMESTAMP)"),
204 {'id': str(uuid.uuid4()), 'tid': tenant_id,
205 'sk': source_kind, 'sid': source_id,
206 'uid': user_id, 'em': emoji})
207 db.commit()
208 except Exception as e:
209 # UNIQUE violation = concurrent toggle won — treat as
210 # successful add (the row is there).
211 db.rollback()
212 logger.info(
213 "ReactionService: concurrent toggle resolved as "
214 "no-op (%s)", e)
215 action = 'added'
216 me_reacted = True
218 # Updated count for this emoji on this source — tenant-scoped
219 # so M-NEW-4: cross-tenant rows can't pollute the count.
220 count = _public_count_for(
221 db, source_kind, source_id, emoji, tenant_id)
223 return {
224 'action': action, 'emoji': emoji,
225 'count': count, 'me_reacted': me_reacted,
226 }
228 # Hard cap on rows fetched for aggregation — defense against a
229 # source with millions of reactions creating a Python-memory hot
230 # spot. Reviewer M-NEW-2. Counts are still aggregated in SQL
231 # via GROUP BY for the dominant emojis.
232 _LIST_FOR_HARD_CAP = 10000
234 @staticmethod
235 def list_for(db, source_kind: str, source_id: str,
236 viewer_id: Optional[str] = None,
237 tenant_id: Optional[str] = None) -> List[dict]:
238 """Return aggregated reactions on a source.
240 Shape per emoji:
241 { 'emoji': str, 'count': int,
242 'users': [<first 5 user_ids>],
243 'me_reacted': bool }
245 Sorted by count DESC then emoji ASC for stable client display.
247 Two-phase aggregation (M-NEW-2 fix):
248 1. SQL GROUP BY emoji → counts (cheap, indexed).
249 2. Bounded SELECT for the user-preview slice (LIMIT 10k) —
250 enough to fill the 5-user preview per emoji on any
251 realistic post; oldest reactors win the preview slot to
252 match the deterministic created_at ASC ordering callers
253 have come to rely on.
254 """
255 from sqlalchemy import text
256 if source_kind not in _VALID_SOURCE_KINDS:
257 raise ReactionError(f"invalid source_kind: {source_kind}")
259 tenant_clause = ""
260 params = {'sk': source_kind, 'sid': source_id}
261 if tenant_id:
262 tenant_clause = " AND (tenant_id = :tid OR tenant_id IS NULL)"
263 params['tid'] = tenant_id
265 # Phase 1 — counts in SQL.
266 count_rows = db.execute(text(
267 f"SELECT emoji, COUNT(*) AS c FROM reactions "
268 f"WHERE source_kind = :sk AND source_id = :sid"
269 f"{tenant_clause} "
270 f"GROUP BY emoji"),
271 params
272 ).fetchall()
273 if not count_rows:
274 return []
275 counts = {row[0]: int(row[1]) for row in count_rows}
277 # Phase 2 — bounded user preview. We pull at most
278 # _LIST_FOR_HARD_CAP rows so a viral post with 1M reactions
279 # doesn't blow up the Python heap; the GROUP BY counts above
280 # are exact regardless of this cap.
281 preview_params = dict(params)
282 preview_params['lim'] = ReactionService._LIST_FOR_HARD_CAP
283 preview_rows = db.execute(text(
284 f"SELECT emoji, user_id FROM reactions "
285 f"WHERE source_kind = :sk AND source_id = :sid"
286 f"{tenant_clause} "
287 f"ORDER BY emoji ASC, created_at ASC LIMIT :lim"),
288 preview_params
289 ).fetchall()
291 users_by_emoji: dict = {}
292 me_by_emoji: dict = {}
293 for emoji, user_id in preview_rows:
294 slot = users_by_emoji.setdefault(emoji, [])
295 if len(slot) < _MAX_USERS_IN_PREVIEW:
296 slot.append(user_id)
297 if viewer_id and user_id == viewer_id:
298 me_by_emoji[emoji] = True
300 out = []
301 for emoji, count in counts.items():
302 out.append({
303 'emoji': emoji, 'count': count,
304 'users': users_by_emoji.get(emoji, []),
305 'me_reacted': me_by_emoji.get(emoji, False),
306 })
307 # If the viewer's reaction was beyond the preview cap, fall back
308 # to a direct lookup for me_reacted so the flag is never wrong.
309 if viewer_id:
310 for slot in out:
311 if not slot['me_reacted']:
312 found = db.execute(text(
313 f"SELECT 1 FROM reactions "
314 f"WHERE source_kind = :sk AND source_id = :sid "
315 f"AND emoji = :em AND user_id = :uid"
316 f"{tenant_clause} LIMIT 1"),
317 {**params, 'em': slot['emoji'], 'uid': viewer_id}
318 ).fetchone()
319 if found:
320 slot['me_reacted'] = True
322 out.sort(key=lambda r: (-r['count'], r['emoji']))
323 return out
325 @staticmethod
326 def remove(db, source_kind: str, source_id: str, user_id: str,
327 emoji: str,
328 tenant_id: Optional[str] = None) -> dict:
329 """Explicit remove (idempotent — no-op if not present).
331 Used by the DELETE endpoint when we want delete-only semantics
332 rather than toggle (e.g., when the client is reconciling local
333 state and wants to remove without risking re-add).
334 """
335 from sqlalchemy import text
336 if source_kind not in _VALID_SOURCE_KINDS:
337 raise ReactionError(f"invalid source_kind: {source_kind}")
338 if emoji not in ALLOWED_EMOJI:
339 raise ReactionError(f"emoji not allowed: {emoji}")
340 delete_params = {'sk': source_kind, 'sid': source_id,
341 'uid': user_id, 'em': emoji}
342 # Tenant scope on the DELETE so a leaked source_id from
343 # another tenant can't be used to mass-clear someone else's
344 # reactions (C-NEW-2 mirror for the delete path).
345 tenant_clause = ""
346 if tenant_id:
347 tenant_clause = " AND (tenant_id = :tid OR tenant_id IS NULL)"
348 delete_params['tid'] = tenant_id
349 db.execute(text(
350 f"DELETE FROM reactions "
351 f"WHERE source_kind = :sk AND source_id = :sid "
352 f"AND user_id = :uid AND emoji = :em{tenant_clause}"),
353 delete_params)
354 db.commit()
355 count = _public_count_for(
356 db, source_kind, source_id, emoji, tenant_id)
357 return {'action': 'removed', 'emoji': emoji,
358 'count': count, 'me_reacted': False}