Coverage for integrations / social / invite_service.py: 0.0%

175 statements  

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

1""" 

2HevolveSocial — Invite service (community + conversation, polymorphic). 

3 

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

5 

6Three invite shapes (one schema): 

7 1. Targeted user — invitee_id set, status='pending' until accept/reject. 

8 2. Off-platform email — invitee_email set, signup-then-accept flow. 

9 3. Shareable link — both NULL, invite_code in URL `/i/<code>`. 

10 

11Acceptance contracts: 

12 - Targeted invite → only invitee_id can accept. 

13 - Email invite → user with matching email after signup can accept. 

14 - Shareable link → first authenticated user to call accept claims it. 

15 

16On accept: 

17 - Insert Membership row (parent_kind/parent_id/member_id) idempotently. 

18 - For community parent: also insert into legacy community_memberships 

19 so existing readers (community_members API) keep working during the 

20 dual-write window (plan Part P.2). 

21 - Status transitions to 'accepted', responded_at stamped. 

22 - Notification fires for the inviter. 

23 

24Transport: relies on NotificationService.create which already publishes 

25through MessageBus (LOCAL → SSE → PEERLINK → CROSSBAR). This module 

26never opens transport sockets directly. 

27 

28Block check: an invite from someone the recipient has blocked is silently 

29dropped at send time — same semantics as MentionService. 

30 

31Coexists with the existing CommunityService.join() one-step flow. 

32A user can still self-join a public community without an invite; this 

33module just adds the invite-required path for private communities and 

34the invite-link UX for public ones. 

35""" 

36 

37from __future__ import annotations 

38 

39import logging 

40import secrets 

41import uuid 

42from datetime import datetime, timedelta 

43from typing import List, Optional 

44 

45logger = logging.getLogger('hevolve_social') 

46 

47 

48_VALID_PARENT_KINDS = ('community', 'conversation') 

49_VALID_ROLES = ('member', 'mod', 'admin') 

50_DEFAULT_EXPIRY_DAYS = 7 

51 

52 

53class InviteError(ValueError): 

54 pass 

55 

56 

57def _gen_invite_code() -> str: 

58 """URL-safe token, ~22 chars. Collision-resistant for the table size.""" 

59 return secrets.token_urlsafe(16) 

60 

61 

62def _is_blocked_either_way(db, x: str, y: str) -> bool: 

63 """Mirror of FriendService._is_blocked_either_way — best-effort check.""" 

64 try: 

65 from sqlalchemy import text 

66 result = db.execute(text( 

67 "SELECT 1 FROM blocks " 

68 "WHERE (blocker_id = :x AND blocked_id = :y) " 

69 "OR (blocker_id = :y AND blocked_id = :x) LIMIT 1"), 

70 {'x': x, 'y': y} 

71 ).fetchone() 

72 return result is not None 

73 except Exception: 

74 return False 

75 

76 

77def _membership_exists(db, parent_kind: str, parent_id: str, 

78 member_id: str) -> bool: 

79 """Whether the recipient is already a member of the parent.""" 

80 try: 

81 from sqlalchemy import text 

82 # Polymorphic Membership table from v41. 

83 result = db.execute(text( 

84 "SELECT 1 FROM memberships " 

85 "WHERE parent_kind = :pk AND parent_id = :pid " 

86 "AND member_id = :mid LIMIT 1"), 

87 {'pk': parent_kind, 'pid': parent_id, 'mid': member_id} 

88 ).fetchone() 

89 if result is not None: 

90 return True 

91 # Fallback: legacy community_memberships during dual-write window. 

92 if parent_kind == 'community': 

93 result = db.execute(text( 

94 "SELECT 1 FROM community_memberships " 

95 "WHERE community_id = :pid AND user_id = :mid LIMIT 1"), 

96 {'pid': parent_id, 'mid': member_id} 

97 ).fetchone() 

98 return result is not None 

99 except Exception: 

100 return False 

101 return False 

102 

103 

104def _insert_membership(db, parent_kind: str, parent_id: str, 

105 member_id: str, role: str, 

106 tenant_id: Optional[str]) -> None: 

107 """Idempotent insert into the polymorphic Membership table. 

108 

109 For community parents, also dual-writes to the legacy 

110 `community_memberships` table during the cut-over window so the 

111 existing readers (community_members API, member_count denorm) 

112 pick up the new member without code change. 

113 """ 

114 from sqlalchemy import text 

115 dialect = db.bind.dialect.name if db.bind is not None else 'sqlite' 

116 if dialect == 'sqlite': 

117 m_stmt = ("INSERT OR IGNORE INTO memberships " 

118 "(id, tenant_id, parent_kind, parent_id, member_id, " 

119 " agent_kind, role, joined_at) " 

120 "VALUES (:id, :tid, :pk, :pid, :mid, 'human', :role, " 

121 " CURRENT_TIMESTAMP)") 

122 else: 

123 m_stmt = ("INSERT INTO memberships " 

124 "(id, tenant_id, parent_kind, parent_id, member_id, " 

125 " agent_kind, role, joined_at) " 

126 "VALUES (:id, :tid, :pk, :pid, :mid, 'human', :role, " 

127 " CURRENT_TIMESTAMP) " 

128 "ON CONFLICT DO NOTHING") 

129 db.execute(text(m_stmt), { 

130 'id': str(uuid.uuid4()), 'tid': tenant_id, 

131 'pk': parent_kind, 'pid': parent_id, 'mid': member_id, 

132 'role': role}) 

133 

134 if parent_kind == 'community': 

135 # Dual-write to the legacy community_memberships table — same 

136 # idempotency strategy. Note: the legacy table uses `created_at` 

137 # (not `joined_at`); confirmed against the live schema. 

138 if dialect == 'sqlite': 

139 cm_stmt = ("INSERT OR IGNORE INTO community_memberships " 

140 "(id, community_id, user_id, role, created_at) " 

141 "VALUES (:id, :pid, :mid, :role, CURRENT_TIMESTAMP)") 

142 else: 

143 cm_stmt = ("INSERT INTO community_memberships " 

144 "(id, community_id, user_id, role, created_at) " 

145 "VALUES (:id, :pid, :mid, :role, CURRENT_TIMESTAMP) " 

146 "ON CONFLICT DO NOTHING") 

147 try: 

148 db.execute(text(cm_stmt), { 

149 'id': str(uuid.uuid4()), 

150 'pid': parent_id, 'mid': member_id, 'role': role}) 

151 # Bump denormed member_count. 

152 db.execute(text( 

153 "UPDATE communities SET member_count = " 

154 "COALESCE(member_count, 0) + 1 WHERE id = :pid"), 

155 {'pid': parent_id}) 

156 except Exception as e: 

157 logger.warning("InviteService: legacy CM dual-write skipped: %s", e) 

158 

159 

160class InviteService: 

161 """All public methods are static. Each takes an explicit `db` so 

162 they can be called from any context without coupling to Flask `g`. 

163 """ 

164 

165 # ── Send ───────────────────────────────────────────────────── 

166 

167 @staticmethod 

168 def send(db, parent_kind: str, parent_id: str, invited_by: str, 

169 invitee_id: Optional[str] = None, 

170 invitee_email: Optional[str] = None, 

171 role_offered: str = 'member', 

172 expires_in_days: Optional[int] = _DEFAULT_EXPIRY_DAYS, 

173 tenant_id: Optional[str] = None) -> dict: 

174 """Create a pending invite + fire notification. 

175 

176 Returns dict with id, invite_code, status, plus the URL-safe 

177 share path the client can include in a deep-link. 

178 

179 Refuses if: 

180 - parent_kind not in valid set 

181 - role_offered not in valid set 

182 - invitee_id == invited_by (cannot self-invite) 

183 - either party blocks the other (silently — matches Mention semantics) 

184 - invitee already a member of parent (returns existing membership idempotently) 

185 """ 

186 from sqlalchemy import text 

187 

188 if parent_kind not in _VALID_PARENT_KINDS: 

189 raise InviteError(f"invalid parent_kind: {parent_kind}") 

190 if role_offered not in _VALID_ROLES: 

191 raise InviteError(f"invalid role_offered: {role_offered}") 

192 if invitee_id and invitee_id == invited_by: 

193 raise InviteError("cannot invite yourself") 

194 if invitee_id and _is_blocked_either_way(db, invited_by, invitee_id): 

195 raise InviteError("cannot send invite") 

196 if invitee_id and _membership_exists(db, parent_kind, parent_id, invitee_id): 

197 return {'status': 'already_member', 

198 'parent_kind': parent_kind, 'parent_id': parent_id} 

199 

200 iid = str(uuid.uuid4()) 

201 code = _gen_invite_code() 

202 expires_at = None 

203 if expires_in_days is not None and expires_in_days > 0: 

204 expires_at = (datetime.utcnow() + timedelta(days=expires_in_days) 

205 ).replace(microsecond=0) 

206 

207 try: 

208 db.execute(text( 

209 "INSERT INTO invites " 

210 "(id, tenant_id, parent_kind, parent_id, invitee_id, " 

211 " invitee_email, invite_code, invited_by, role_offered, " 

212 " status, created_at, expires_at) " 

213 "VALUES (:id, :tid, :pk, :pid, :uid, :em, :code, :ib, " 

214 " :role, 'pending', CURRENT_TIMESTAMP, :exp)"), 

215 {'id': iid, 'tid': tenant_id, 'pk': parent_kind, 

216 'pid': parent_id, 'uid': invitee_id, 'em': invitee_email, 

217 'code': code, 'ib': invited_by, 'role': role_offered, 

218 'exp': expires_at}) 

219 db.commit() 

220 except Exception as e: 

221 logger.warning("InviteService.send: insert failed: %s", e) 

222 raise InviteError(f"failed to create invite: {e}") 

223 

224 # Notify targeted invitee. Email + shareable-link invites can't 

225 # fire an in-app notification (no user row to address), so we 

226 # skip — the inviter is responsible for sharing the link. 

227 if invitee_id: 

228 InviteService._notify_invitee( 

229 db, iid, invited_by, invitee_id, parent_kind, parent_id) 

230 

231 return { 

232 'id': iid, 'invite_code': code, 'status': 'pending', 

233 'parent_kind': parent_kind, 'parent_id': parent_id, 

234 'role_offered': role_offered, 

235 'expires_at': str(expires_at) if expires_at else None, 

236 } 

237 

238 # ── Accept / reject ────────────────────────────────────────── 

239 

240 @staticmethod 

241 def accept(db, invite_id_or_code: str, accepter_id: str, 

242 tenant_id: Optional[str] = None) -> dict: 

243 """Accept an invite by id OR by code (shareable link path). 

244 

245 Idempotent: if the same user re-accepts an already-accepted 

246 invite they own, returns the existing acceptance. 

247 

248 Refuses if: 

249 - invite not found 

250 - invite expired 

251 - invite already accepted/rejected by someone else 

252 - targeted invite + accepter != invitee 

253 """ 

254 invite = InviteService._lookup(db, invite_id_or_code) 

255 if invite is None: 

256 raise InviteError("invite not found") 

257 

258 iid = invite['id'] 

259 status = invite['status'] 

260 invitee_id = invite['invitee_id'] 

261 parent_kind = invite['parent_kind'] 

262 parent_id = invite['parent_id'] 

263 role = invite['role_offered'] 

264 expires_at = invite['expires_at'] 

265 

266 # Expiry check. 

267 if expires_at and InviteService._is_expired(expires_at): 

268 InviteService._mark_expired(db, iid) 

269 raise InviteError("invite expired") 

270 

271 if status == 'accepted': 

272 # Idempotent for the same user; refuse for a different user 

273 # claiming an already-claimed shareable link. 

274 if invitee_id == accepter_id: 

275 return {'id': iid, 'status': 'accepted', 

276 'parent_kind': parent_kind, 'parent_id': parent_id} 

277 raise InviteError("invite already accepted") 

278 if status == 'rejected': 

279 raise InviteError("invite already rejected") 

280 if status == 'expired': 

281 raise InviteError("invite expired") 

282 

283 # Targeted invite: only the named invitee can accept. 

284 if invitee_id is not None and invitee_id != accepter_id: 

285 raise InviteError("not the invitee") 

286 

287 # Insert Membership (idempotent). 

288 _insert_membership(db, parent_kind, parent_id, accepter_id, role, 

289 tenant_id) 

290 

291 # Mark invite accepted, claim the slot for shareable links by 

292 # stamping invitee_id with the accepter. 

293 from sqlalchemy import text 

294 db.execute(text( 

295 "UPDATE invites SET status='accepted', " 

296 "responded_at=CURRENT_TIMESTAMP, " 

297 "invitee_id=COALESCE(invitee_id, :aid) " 

298 "WHERE id=:id"), 

299 {'aid': accepter_id, 'id': iid}) 

300 db.commit() 

301 

302 # Notify inviter that someone accepted. 

303 try: 

304 from .services import NotificationService 

305 NotificationService.create( 

306 db, user_id=invite['invited_by'], type='invite_accepted', 

307 source_user_id=accepter_id, 

308 target_type=parent_kind, target_id=parent_id, 

309 message="Your invite was accepted") 

310 except Exception as e: 

311 logger.warning("InviteService.accept: notify failed: %s", e) 

312 

313 return {'id': iid, 'status': 'accepted', 

314 'parent_kind': parent_kind, 'parent_id': parent_id, 

315 'role': role} 

316 

317 @staticmethod 

318 def reject(db, invite_id_or_code: str, rejecter_id: str) -> dict: 

319 from sqlalchemy import text 

320 invite = InviteService._lookup(db, invite_id_or_code) 

321 if invite is None: 

322 raise InviteError("invite not found") 

323 if invite['invitee_id'] is not None and invite['invitee_id'] != rejecter_id: 

324 raise InviteError("not the invitee") 

325 if invite['status'] != 'pending': 

326 raise InviteError(f"cannot reject from status={invite['status']}") 

327 db.execute(text( 

328 "UPDATE invites SET status='rejected', " 

329 "responded_at=CURRENT_TIMESTAMP WHERE id=:id"), 

330 {'id': invite['id']}) 

331 db.commit() 

332 return {'id': invite['id'], 'status': 'rejected'} 

333 

334 # ── Read paths ─────────────────────────────────────────────── 

335 

336 @staticmethod 

337 def list_incoming(db, user_id: str, 

338 include_responded: bool = False) -> List[dict]: 

339 """Pending invites targeted at user_id (by id OR by their email). 

340 

341 Off-platform email invites are surfaced once the user signs up 

342 with that email — this query joins on the user's email so the 

343 inbox shows them on first login. 

344 """ 

345 from sqlalchemy import text 

346 from .models import User 

347 

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

349 if user is None: 

350 return [] 

351 email = (user.email or '').lower() or None 

352 

353 where = "(invitee_id = :uid" 

354 params = {'uid': user_id} 

355 if email: 

356 where += " OR LOWER(invitee_email) = :em" 

357 params['em'] = email 

358 where += ")" 

359 

360 if not include_responded: 

361 where += " AND status = 'pending'" 

362 

363 rows = db.execute(text( 

364 f"SELECT id, parent_kind, parent_id, invited_by, role_offered, " 

365 f" status, created_at, expires_at, invite_code " 

366 f"FROM invites WHERE {where} " 

367 f"ORDER BY created_at DESC"), 

368 params 

369 ).fetchall() 

370 

371 out = [] 

372 for row in rows: 

373 iid, pk, pid, invited_by, role, status, created_at, expires_at, code = row 

374 # Skip expired invites silently in the incoming list. 

375 if status == 'pending' and expires_at and \ 

376 InviteService._is_expired(str(expires_at)): 

377 continue 

378 out.append({ 

379 'id': iid, 'parent_kind': pk, 'parent_id': pid, 

380 'invited_by': invited_by, 'role_offered': role, 

381 'status': status, 'created_at': str(created_at) if created_at else None, 

382 'expires_at': str(expires_at) if expires_at else None, 

383 'invite_code': code, 

384 }) 

385 return out 

386 

387 @staticmethod 

388 def resolve_code(db, code: str) -> Optional[dict]: 

389 """Look up a pending invite by code. Used when a user opens a 

390 share link — the client previews the parent + role before the 

391 accept call. Returns None if code unknown / expired / consumed. 

392 """ 

393 from sqlalchemy import text 

394 row = db.execute(text( 

395 "SELECT id, parent_kind, parent_id, invited_by, role_offered, " 

396 " status, created_at, expires_at, invitee_id " 

397 "FROM invites WHERE invite_code = :code"), 

398 {'code': code} 

399 ).fetchone() 

400 if row is None: 

401 return None 

402 iid, pk, pid, invited_by, role, status, created_at, expires_at, invitee_id = row 

403 if status != 'pending': 

404 return None 

405 if expires_at and InviteService._is_expired(str(expires_at)): 

406 InviteService._mark_expired(db, iid) 

407 return None 

408 return { 

409 'id': iid, 'parent_kind': pk, 'parent_id': pid, 

410 'invited_by': invited_by, 'role_offered': role, 

411 'is_targeted': invitee_id is not None, 

412 'expires_at': str(expires_at) if expires_at else None, 

413 } 

414 

415 # ── Internal helpers ───────────────────────────────────────── 

416 

417 @staticmethod 

418 def _lookup(db, invite_id_or_code: str) -> Optional[dict]: 

419 """Look up an invite by primary id or by invite_code.""" 

420 from sqlalchemy import text 

421 row = db.execute(text( 

422 "SELECT id, parent_kind, parent_id, invitee_id, invitee_email, " 

423 " invited_by, role_offered, status, created_at, expires_at " 

424 "FROM invites WHERE id = :v OR invite_code = :v"), 

425 {'v': invite_id_or_code} 

426 ).fetchone() 

427 if row is None: 

428 return None 

429 return { 

430 'id': row[0], 'parent_kind': row[1], 'parent_id': row[2], 

431 'invitee_id': row[3], 'invitee_email': row[4], 

432 'invited_by': row[5], 'role_offered': row[6], 

433 'status': row[7], 'created_at': row[8], 

434 'expires_at': str(row[9]) if row[9] else None, 

435 } 

436 

437 @staticmethod 

438 def _is_expired(expires_at_str: str) -> bool: 

439 """Compare ISO/SQLite-formatted expires_at to now.""" 

440 if not expires_at_str: 

441 return False 

442 try: 

443 # SQLite stores "YYYY-MM-DD HH:MM:SS"; Postgres stores ISO. 

444 ts = expires_at_str.replace('T', ' ').split('.')[0] 

445 return datetime.utcnow() >= datetime.strptime( 

446 ts, '%Y-%m-%d %H:%M:%S') 

447 except Exception: 

448 return False 

449 

450 @staticmethod 

451 def _mark_expired(db, invite_id: str) -> None: 

452 from sqlalchemy import text 

453 try: 

454 db.execute(text( 

455 "UPDATE invites SET status='expired' " 

456 "WHERE id = :id AND status = 'pending'"), 

457 {'id': invite_id}) 

458 db.commit() 

459 except Exception: 

460 pass 

461 

462 @staticmethod 

463 def _notify_invitee(db, invite_id: str, invited_by: str, 

464 invitee_id: str, parent_kind: str, 

465 parent_id: str) -> None: 

466 try: 

467 from .services import NotificationService 

468 NotificationService.create( 

469 db, user_id=invitee_id, type='invite', 

470 source_user_id=invited_by, 

471 target_type=parent_kind, target_id=parent_id, 

472 message=f"You were invited to a {parent_kind}") 

473 except Exception as e: 

474 logger.warning("InviteService: notify_invitee failed: %s", e)