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
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial — Invite service (community + conversation, polymorphic).
4Phase 7c.2. Plan reference: sunny-gliding-eich.md, Part E.9.
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>`.
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.
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.
24Transport: relies on NotificationService.create which already publishes
25through MessageBus (LOCAL → SSE → PEERLINK → CROSSBAR). This module
26never opens transport sockets directly.
28Block check: an invite from someone the recipient has blocked is silently
29dropped at send time — same semantics as MentionService.
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"""
37from __future__ import annotations
39import logging
40import secrets
41import uuid
42from datetime import datetime, timedelta
43from typing import List, Optional
45logger = logging.getLogger('hevolve_social')
48_VALID_PARENT_KINDS = ('community', 'conversation')
49_VALID_ROLES = ('member', 'mod', 'admin')
50_DEFAULT_EXPIRY_DAYS = 7
53class InviteError(ValueError):
54 pass
57def _gen_invite_code() -> str:
58 """URL-safe token, ~22 chars. Collision-resistant for the table size."""
59 return secrets.token_urlsafe(16)
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
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
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.
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})
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)
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 """
165 # ── Send ─────────────────────────────────────────────────────
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.
176 Returns dict with id, invite_code, status, plus the URL-safe
177 share path the client can include in a deep-link.
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
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}
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)
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}")
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)
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 }
238 # ── Accept / reject ──────────────────────────────────────────
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).
245 Idempotent: if the same user re-accepts an already-accepted
246 invite they own, returns the existing acceptance.
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")
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']
266 # Expiry check.
267 if expires_at and InviteService._is_expired(expires_at):
268 InviteService._mark_expired(db, iid)
269 raise InviteError("invite expired")
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")
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")
287 # Insert Membership (idempotent).
288 _insert_membership(db, parent_kind, parent_id, accepter_id, role,
289 tenant_id)
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()
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)
313 return {'id': iid, 'status': 'accepted',
314 'parent_kind': parent_kind, 'parent_id': parent_id,
315 'role': role}
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'}
334 # ── Read paths ───────────────────────────────────────────────
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).
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
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
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 += ")"
360 if not include_responded:
361 where += " AND status = 'pending'"
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()
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
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 }
415 # ── Internal helpers ─────────────────────────────────────────
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 }
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
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
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)