Coverage for functions \ flipdare \ service \ processor \ invite_processor.py: 37%
219 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
1#!/usr/bin/env python
2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
3#
4# This file is part of Flipdare's proprietary software and contains
5# confidential and copyrighted material. Unauthorised copying,
6# modification, distribution, or use of this file is strictly
7# prohibited without prior written permission from Flipdare Pty Ltd.
8#
9# This software includes third-party components licensed under MIT,
10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
11#
13"""
14InviteProcessor - Handles all invite-related processing logic.
15Extracted from FriendAdmin for better testability and maintainability.
16"""
18from typing import Any
19from flipdare.backend.indexer_service import IndexerService
20from flipdare.app_globals import is_text_present
21from flipdare.app_log import LOG
22from flipdare.app_types import FriendBridge, InviteBridge, UserBridge
23from flipdare.constants import IS_DEBUG, NO_DOC_ID
24from flipdare.result.app_result import AppResult
25from flipdare.result.job_result import JobResult
26from flipdare.service.core.step_processor import ProcessingStep, StepProcessor
27from flipdare.mailer.user.invite_email import InviteEmail
28from flipdare.mailer.user_mailer import UserMailer
29from flipdare.generated import AppErrorCode, FriendType, RequestStatus
30from flipdare.generated.model.friend_model import FriendModel
31from flipdare.generated.model.invite_model import InviteKeys
32from flipdare.generated.model.user_model import UserModel
33from flipdare.util.code_generator import CodeGenerator
34from flipdare.util.time_util import TimeUtil
35from flipdare.wrapper import (
36 FriendWrapper,
37 InviteWrapper,
38 PersistedGuard,
39 UserWrapper,
40)
42_K = InviteKeys
45class InviteProcessor:
46 """Handles invite workflow processing with automatic state management."""
48 def __init__(
49 self,
50 invite_bridge: InviteBridge,
51 user_bridge: UserBridge,
52 friend_bridge: FriendBridge,
53 indexer_service: IndexerService,
54 mailer: UserMailer,
55 ) -> None:
56 """
57 Initialize with required dependencies.
59 Args:
60 invite_bridge: Bridge for invite-related operations
61 user_bridge: Bridge for user-related operations
62 friend_bridge: Bridge for friend-related operations
63 indexer_service: Search admin for indexing
65 """
66 self.invite_bridge = invite_bridge
67 self.user_bridge = user_bridge
68 self.friend_bridge = friend_bridge
69 self.indexer_service = indexer_service
70 self.mailer = mailer
72 def process_new_invite(self, invite: InviteWrapper) -> JobResult[InviteWrapper]:
73 """
74 Process a new invite (Step 1: Initial).
76 Steps:
77 1a. Create temporary UserWrapper with signup code
78 1b. Send invite email with signup code
79 """
80 invite_id = invite.doc_id
81 # Check if already complete
82 if invite.processed:
83 msg = f"Invite already processed for {invite.to_email}"
84 LOG().info(msg)
85 return JobResult.skip_doc(doc_id=invite_id, message=msg)
87 # Use StepProcessor for the workflow
89 steps: list[ProcessingStep[_K, InviteWrapper]] = [
90 ProcessingStep[_K, InviteWrapper](
91 state_key=_K.USER_CREATED,
92 handler=lambda m: self._create_invite_user(m),
93 description="Create temporary user with signup code",
94 required=True,
95 ),
96 ProcessingStep[_K, InviteWrapper](
97 state_key=_K.EMAIL_SENT,
98 handler=lambda m: self._send_invite_email(m, is_reminder=False),
99 description="Send invite email",
100 required=True,
101 ),
102 ]
104 processor = StepProcessor(
105 wrapper=invite,
106 steps=steps,
107 save_handler=lambda m: self.invite_bridge.update(m),
108 process_name=f"new_invite_{invite_id}",
109 )
111 result = processor.execute()
113 if result.is_error:
114 msg = (
115 f"Error processing new invite {invite_id} to {invite.to_email}: {result.formatted}"
116 )
117 LOG().error(msg)
118 return JobResult.from_result(
119 result,
120 doc_id=invite_id,
121 data=invite.to_json_dict(),
122 )
123 return JobResult.ok(doc_id=invite_id)
125 def process_invite_signup(self, invite: InviteWrapper) -> JobResult[InviteWrapper]:
126 """Process when invited user signs up - creates friend relationships."""
127 # Shared dict for passing data between steps
128 shared_data: dict[str, Any] = {}
130 def create_friend_step(model: InviteWrapper) -> AppResult[FriendWrapper]:
131 shared_data["doc_id"] = model.doc_id
132 result = self._create_friend_relationship(from_uid=model.from_uid, to_uid=model.to_uid)
133 if result.is_ok:
134 shared_data["friend_result"] = result
135 return result
137 def update_search_step(_: Any) -> AppResult[FriendWrapper]:
138 # NOTE: we need to use the shared data, so we get the latest data
139 friend_result = shared_data.get("friend_result")
140 if friend_result is not None:
141 return self._update_search_index(friend_result)
143 return AppResult[FriendWrapper].skip(doc_id=shared_data.get("doc_id", NO_DOC_ID))
145 # Entry point
146 invite_id = invite.doc_id
148 # Check if already complete
149 if invite.processing_complete:
150 msg = f"Invite already processed for {invite.to_email}"
151 if IS_DEBUG:
152 LOG().debug(msg)
154 return JobResult.skip_doc(doc_id=invite_id, message=msg)
156 steps: list[ProcessingStep[_K, InviteWrapper]] = [
157 ProcessingStep[_K, InviteWrapper](
158 state_key=_K.FRIENDS_CREATED,
159 handler=create_friend_step,
160 description="Create friend relationships",
161 required=True,
162 ),
163 ProcessingStep[_K, InviteWrapper](
164 state_key=_K.SEARCH_INDEXED,
165 handler=update_search_step,
166 description="Add to search index",
167 required=False,
168 ),
169 ]
171 processor = StepProcessor(
172 wrapper=invite,
173 steps=steps,
174 save_handler=lambda m: self.invite_bridge.update(m),
175 process_name=f"signed_up_invite_{invite.doc_id}",
176 )
178 result = processor.execute()
180 if result.is_error:
181 return JobResult.from_result(
182 result,
183 doc_id=invite_id,
184 data=invite.to_json_dict(),
185 )
186 return JobResult.ok(doc_id=invite_id)
188 # ========================================================================
189 # CRON Jobs
190 # ========================================================================
192 def process_invite_reminder(self, invite: InviteWrapper) -> JobResult[InviteWrapper]:
193 """Send invite reminder for invites older than 7 days."""
194 doc_id = invite.doc_id
195 if IS_DEBUG:
196 LOG().debug(f"Processing invite reminder for invite {doc_id} to {invite.to_email}")
198 main_result = AppResult[InviteWrapper](doc_id=doc_id)
200 one_week_ago = TimeUtil.get_utc_time_days_ago(7)
201 if invite.created_at < one_week_ago:
202 # since the query should return > one week old invites, this is unexpected
203 msg = (
204 f"Invite reminder: Invite {doc_id} to {invite.to_email} "
205 f"is not older than 7 days (created_at={invite.created_at})"
206 )
207 LOG().warning(msg)
208 main_result.add_warning(msg)
209 return JobResult.skip_doc(doc_id=doc_id, message=msg)
211 to_email = invite.to_email
212 # Skip if already processed
213 if invite.reminder_sent or invite.processing_complete:
214 if IS_DEBUG:
215 LOG().debug(
216 f"Skipping invite reminder for {doc_id} to {to_email}, "
217 f"state={invite.internal_state!s}",
218 )
219 return JobResult.skip_doc(doc_id=doc_id, message="Invite already processed")
221 # Get user
222 to_user = self.user_bridge.db.get_user_by_email(to_email)
223 if to_user is None:
224 msg = f"Invite reminder: No user found for invite {doc_id} to {to_email}"
225 LOG().error(msg)
226 main_result.add_error(AppErrorCode.NOT_FOUND, msg)
227 return JobResult.from_result(
228 main_result,
229 data=invite.to_json_dict(),
230 message=msg,
231 )
233 # Ensure user has pin code
234 pin_code = to_user.pin_code
235 if pin_code is None:
236 pin_code = CodeGenerator.instance().signup_code()
237 to_user.pin_code = pin_code
238 update_result = self.user_bridge.update(to_user)
239 if update_result.is_error:
240 cause = (
241 f"Invite reminder: Failed to save pin code for user {to_user.doc_id} "
242 f"for invite {doc_id} to {to_email}\n{update_result.formatted}"
243 )
244 LOG().error(cause)
245 main_result.add_error(AppErrorCode.DATABASE_EX, cause)
246 return JobResult.from_result(
247 main_result,
248 data=invite.to_json_dict(),
249 message=cause,
250 )
252 # Send reminder email
253 email_result = self._send_invite_email(invite, is_reminder=True)
254 if email_result.is_ok:
255 if IS_DEBUG:
256 LOG().debug(f"Sent invite reminder for invite: {doc_id} to {to_email}")
257 return JobResult.ok(doc_id=doc_id)
259 msg = f"Invite reminder: Failed to send email for invite {doc_id} to {to_email}"
260 LOG().error(msg)
261 main_result.add_error(AppErrorCode.INVALID_EMAIL, msg)
262 return JobResult.from_result(main_result, data=invite.to_json_dict(), message=msg)
264 # ========================================================================
265 # Step Handlers
266 # ========================================================================
268 def _create_invite_user(self, invite: InviteWrapper) -> AppResult[UserWrapper]:
269 """Create temporary user account for invited user."""
270 invite_id = invite.doc_id
271 main_result = AppResult[UserWrapper](doc_id=invite_id)
273 pin_code = CodeGenerator.instance().signup_code()
274 to_email = invite.to_email
275 to_name = invite.to_name
277 try:
278 model = UserModel.create_invite(
279 email=to_email,
280 name=to_name,
281 invite_id=invite_id,
282 pin_code=pin_code,
283 )
285 user_result = self.user_bridge.create(model)
286 if user_result.is_error:
287 main_result.merge(user_result)
289 except Exception as error:
290 cause = f"Failed to create user for invite {invite.to_email}: {error}"
291 LOG().error(cause)
292 main_result.add_error(AppErrorCode.DATABASE_EX, cause)
294 return main_result
296 def _send_invite_email(
297 self,
298 invite_obj: InviteWrapper | str,
299 is_reminder: bool,
300 ) -> AppResult[InviteWrapper]:
301 """Send invite email with signup code."""
302 main_result = AppResult[InviteWrapper]()
303 invite_result = self._get_invite(invite_obj)
304 if invite_result.is_error:
305 LOG().error(f"Failed to get invite for sending email: {invite_result.formatted}")
306 main_result.merge(invite_result)
307 return main_result
309 invite = invite_result.generated
310 assert invite is not None # narrowing
312 invite_id = invite.doc_id
313 main_result.doc_id = invite_id
315 invite_id = invite.doc_id
316 user_result = self.user_bridge.get(invite.to_uid)
317 if user_result.is_error:
318 LOG().error(
319 f"Failed to get invited user for invite {invite_id}: {user_result.formatted}"
320 )
321 main_result.merge(user_result)
322 return main_result
324 invited_user = user_result.generated
325 assert invited_user
327 pin_code = invited_user.pin_code
328 if pin_code is None or not is_text_present(pin_code):
329 msg = (
330 f"Invite email: No pin code found for invited user "
331 f"{invited_user.doc_id} for invite {invite_id}"
332 )
333 LOG().error(msg)
334 main_result.add_error(AppErrorCode.MISSING_DATA, msg)
335 return main_result
337 email_template: InviteEmail | None = None
338 try:
339 email_template = InviteEmail(
340 invite=invite,
341 signup_code=pin_code,
342 is_reminder=is_reminder,
343 )
344 except Exception as error:
345 msg = f"Failed to create invite email template for invite {invite_id}: {error}"
346 LOG().error(msg)
347 main_result.add_error(AppErrorCode.UNEXPECTED_CODE_PATH, msg)
348 return main_result
350 assert email_template is not None # narrowing
351 try:
352 ok = self.mailer.send(
353 user=invited_user,
354 email_template=email_template,
355 notif_check=False,
356 )
357 if not ok:
358 msg = f"Failed to send invite email to {invite.to_email} for invite {invite_id}"
359 LOG().error(msg)
360 main_result.add_error(AppErrorCode.INVALID_EMAIL, msg)
361 return main_result
362 except Exception as error:
363 msg = f"Exception sending invite email to {invite.to_email} for invite {invite_id}: {error}"
364 LOG().error(msg)
365 main_result.add_error(AppErrorCode.INVALID_EMAIL, msg)
366 return main_result
368 return main_result
370 def _create_friend_relationship(
371 self,
372 from_uid: str,
373 to_uid: str,
374 ) -> AppResult[FriendWrapper]:
375 """Create friend relationship for invite signup."""
376 main_result = AppResult[FriendWrapper](task_name=f"create_friend_{from_uid}_to_{to_uid}")
378 try:
379 friend_model = FriendModel(
380 id=None,
381 from_uid=from_uid,
382 to_uid=to_uid,
383 friend_type=FriendType.INVITE,
384 status=RequestStatus.ACCEPTED,
385 )
386 friend_result = self.friend_bridge.create(friend_model)
387 if friend_result.is_error:
388 main_result.merge(friend_result)
389 return main_result
391 saved_friend = friend_result.generated
392 assert isinstance(saved_friend, FriendWrapper) # narrowing
393 main_result.generated = saved_friend
395 except Exception as error:
396 msg = f"Failed to create friend from {from_uid} to {to_uid}: {error}"
397 LOG().error(msg)
398 main_result.add_error(AppErrorCode.DATABASE_EX, message=msg)
400 return main_result
402 def _update_search_index(
403 self,
404 friend_result: AppResult[FriendWrapper],
405 ) -> AppResult[FriendWrapper]:
406 """Add friend relationship to search index."""
407 from flipdare.firestore.context.friend_context import FriendContextFactory
409 main_result = AppResult[FriendWrapper]()
410 if friend_result.is_error:
411 main_result.merge(friend_result)
412 return main_result
414 friend = friend_result.generated
415 assert friend
417 main_result.doc_id = friend.doc_id
418 try:
419 friend_context = FriendContextFactory().create(friend)
420 if not friend_context:
421 main_result.add_error(
422 AppErrorCode.CONTEXT,
423 f"Failed to build FriendContext for friend {friend.doc_id}",
424 )
425 return main_result
427 self.indexer_service.process_friend(friend_context, updated=True)
429 except Exception as e:
430 msg = f"Exception updating search for friend {friend.doc_id}: {e}"
431 LOG().error(msg)
432 main_result.add_error(AppErrorCode.SEARCH, msg)
434 return main_result
436 # ========================================================================
437 # Helper Methods
438 # ========================================================================
440 def _get_invite(self, invite_obj: InviteWrapper | str) -> AppResult[InviteWrapper]:
441 """Get InviteWrapper from object or ID."""
442 doc_id = invite_obj.doc_id if isinstance(invite_obj, InviteWrapper) else invite_obj
443 main_result = AppResult[InviteWrapper](doc_id=doc_id)
445 if PersistedGuard.is_invite(invite_obj):
446 LOG().debug(f"Using provided InviteWrapper for invite to {invite_obj.to_email}")
447 main_result.generated = invite_obj
448 return main_result
450 assert isinstance(invite_obj, str) # narrowing, should be true if not InviteWrapper
451 LOG().debug(f"Fetching InviteWrapper for invite id {invite_obj}")
452 invite_id = invite_obj
453 get_result = self.invite_bridge.get(invite_id)
454 if get_result.is_error:
455 main_result.merge(get_result)
457 return main_result