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

1""" 

2HevolveSocial — Emoji reactions on posts / comments / messages. 

3 

4Phase 7c.4. Plan reference: sunny-gliding-eich.md, Part E.6. 

5 

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. 

9 

10Polymorphic by (source_kind, source_id): 

11 source_kind ∈ {'post', 'comment', 'message'}. 

12 source_id is the primary key of the source row. 

13 

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. 

20 

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. 

24 

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. 

28 

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""" 

37 

38from __future__ import annotations 

39 

40import logging 

41import uuid 

42from typing import List, Optional, Tuple 

43 

44logger = logging.getLogger('hevolve_social') 

45 

46 

47_VALID_SOURCE_KINDS = ('post', 'comment', 'message') 

48 

49# Whitelist matches plan Part E.6. Per-tenant override comes in Phase 8. 

50ALLOWED_EMOJI = frozenset([ 

51 '👍', '❤️', '🔥', '😂', '😢', '😮', '🎉', '👎', '🚀', 

52]) 

53 

54_MAX_USERS_IN_PREVIEW = 5 

55 

56 

57class ReactionError(ValueError): 

58 pass 

59 

60 

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 

73 

74 

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. 

78 

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. 

82 

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 

106 

107 

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 

127 

128 

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 """ 

133 

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. 

139 

140 Returns: 

141 { 'action': 'added'|'removed', 'emoji': str, 

142 'count': int, 'me_reacted': bool } 

143 

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 

151 

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}") 

156 

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 } 

177 

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() 

186 

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 

217 

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) 

222 

223 return { 

224 'action': action, 'emoji': emoji, 

225 'count': count, 'me_reacted': me_reacted, 

226 } 

227 

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 

233 

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. 

239 

240 Shape per emoji: 

241 { 'emoji': str, 'count': int, 

242 'users': [<first 5 user_ids>], 

243 'me_reacted': bool } 

244 

245 Sorted by count DESC then emoji ASC for stable client display. 

246 

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}") 

258 

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 

264 

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} 

276 

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() 

290 

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 

299 

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 

321 

322 out.sort(key=lambda r: (-r['count'], r['emoji'])) 

323 return out 

324 

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). 

330 

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}