Coverage for functions \ flipdare \ service \ processor \ friend_processor.py: 0%
162 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"""
14FriendProcessor - Handles all friend-related processing logic.
15Extracted from FriendAdmin for better testability and maintainability.
16"""
18from typing import Any
20from flipdare.backend.indexer_service import IndexerService
21from flipdare.service.notification_service import NotificationService
22from flipdare.service.user_summary_service import UserSummaryService
23from flipdare.app_log import LOG
24from flipdare.app_types import DatabaseDict, FriendBridge
25from flipdare.constants import NO_DOC_ID
26from flipdare.result.app_result import AppResult
27from flipdare.result.job_result import JobResult
28from flipdare.service.core.step_processor import ProcessingStep, StepProcessor
29from flipdare.firestore.context.friend_context import FriendContext, FriendContextFactory
30from flipdare.generated import AppErrorCode, RequestStatus
31from flipdare.generated.model.friend_model import FriendKeys
32from flipdare.wrapper import (
33 FriendWrapper,
34 NotificationWrapper,
35 PersistedGuard,
36)
37from flipdare.wrapper.backend.user_summary_entry_wrapper import UserSummaryEntryWrapper
39_K = FriendKeys
42class FriendProcessor:
43 """Handles friend workflow processing with automatic state management."""
45 def __init__(
46 self,
47 friend_bridge: FriendBridge,
48 notification_service: NotificationService,
49 indexer_service: IndexerService,
50 summary_service: UserSummaryService,
51 ) -> None:
52 self.friend_bridge = friend_bridge
53 self.notification_service = notification_service
54 self.indexer_service = indexer_service
55 self.summary_service = summary_service
57 def process_new_friend(self, friend: FriendWrapper) -> JobResult[FriendWrapper]:
58 """
59 Process new friend request (Step 1: Initial).
61 Steps:
62 1a. Verify friend record exists
63 1b. Send email to requested friend
64 1c. Send notification to requested friend
65 """
66 friend_id = friend.doc_id
68 processor = self._build_processor(
69 friend_id=friend_id,
70 friend_model=friend,
71 is_update=False,
72 )
73 result = processor.execute()
75 if result.is_error:
76 return JobResult.from_result(
77 result,
78 doc_id=friend_id,
79 data=friend.to_dict_with_id(),
80 )
81 return JobResult.ok(doc_id=friend_id)
83 def process_friend_update(
84 self, friend_obj: FriendWrapper | DatabaseDict
85 ) -> JobResult[FriendWrapper]:
86 """
87 Process friend update (Step 2: Friend accepts/rejects/blocks).
89 Steps for ACCEPTED status:
90 2a. Get context
91 2b. Create reciprocal FriendModel
92 2c. Send email to owner
93 2d. Send notification to owner
94 2e. Update search index
95 """
96 main_result = AppResult[FriendWrapper]()
98 # Parse input
99 friend_model: FriendWrapper | None = None
101 if PersistedGuard.is_friend(friend_obj):
102 friend_model = friend_obj
103 else:
104 assert isinstance(friend_obj, dict) # narrowing
105 friend_data: DatabaseDict = friend_obj
106 try:
107 friend_model = FriendWrapper.from_dict(friend_data)
108 except Exception as e:
109 msg = f"Exception creating FriendModel: {e}"
110 main_result.add_error(AppErrorCode.INVALID_DATA, msg)
111 return JobResult.from_result(main_result, data=friend_data, message=msg)
113 friend_id = friend_model.doc_id
114 status = friend_model.status
115 main_result.doc_id = friend_id
117 # Check if update is needed
118 if status != RequestStatus.ACCEPTED and not status.remove_from_search:
119 msg = f"Friend update not required for status={status}"
120 return JobResult.skip_doc(doc_id=friend_id, message=msg)
122 processor = self._build_processor(
123 friend_id=friend_id,
124 friend_model=friend_model,
125 is_update=True,
126 )
127 result = processor.execute()
129 if result.is_error:
130 return JobResult.from_result(
131 result,
132 doc_id=friend_id,
133 data=friend_model.to_dict_with_id(),
134 )
135 return JobResult.ok(doc_id=friend_id)
137 def _build_processor(
138 self,
139 friend_id: str,
140 friend_model: FriendWrapper,
141 is_update: bool,
142 ) -> StepProcessor[FriendWrapper]:
143 """Helper to build StepProcessor for friend workflows."""
144 status = friend_model.status
146 shared_data: dict[str, Any] = {}
148 def get_context_step(model: FriendWrapper) -> AppResult[FriendContext]:
149 result = self._get_friend_context(model)
150 if result.is_ok and result.generated:
151 shared_data["context"] = result.generated
152 shared_data["doc_id"] = result.generated.doc_id
153 return result
155 def update_search_step(model: FriendWrapper) -> AppResult[FriendWrapper]:
156 context = shared_data.get("context")
157 if not context:
158 error_result = AppResult[FriendWrapper](
159 doc_id=shared_data.get("doc_id", NO_DOC_ID),
160 )
161 error_result.add_error(
162 AppErrorCode.CONTEXT,
163 "No context available for search update",
164 )
165 return error_result
167 return self._update_search_for_friend(model, context)
169 steps = []
170 if not is_update:
171 steps = [
172 ProcessingStep[_K, FriendWrapper](
173 state_key=_K.CREATED,
174 handler=lambda m: self._verify_friend(m),
175 description="Verify friend record exists",
176 required=True,
177 ),
178 ProcessingStep[_K, FriendWrapper](
179 state_key=_K.EMAIL_SENT,
180 handler=lambda m: self._create_summary_entry(m),
181 description="Create email summary entry",
182 required=False, # Optional
183 ),
184 ProcessingStep[_K, FriendWrapper](
185 state_key=_K.NOTIFICATION_SENT,
186 handler=lambda m: self._send_notifications(m, is_update=False),
187 description="Send friend request notification",
188 required=True,
189 ),
190 ]
191 else:
192 steps = [
193 ProcessingStep[_K, FriendWrapper](
194 state_key=_K.CONTEXT_CREATED,
195 handler=get_context_step,
196 description="Get friend context",
197 required=True,
198 ),
199 ]
201 if status == RequestStatus.ACCEPTED:
202 steps.extend(
203 [
204 ProcessingStep[_K, FriendWrapper](
205 state_key=_K.EMAIL_SENT,
206 handler=lambda m: self._create_summary_entry(m),
207 description="Send acceptance email",
208 required=False,
209 ),
210 ProcessingStep[_K, FriendWrapper](
211 state_key=_K.NOTIFICATION_SENT,
212 handler=lambda m: self._send_notifications(m, is_update=True),
213 description="Send notification",
214 required=True,
215 ),
216 ],
217 )
219 steps.append(
220 ProcessingStep[_K, FriendWrapper](
221 state_key=_K.SEARCH_INDEXED,
222 handler=update_search_step,
223 description="Update search index",
224 required=False,
225 ),
226 )
228 label = "new" if not is_update else "update"
229 return StepProcessor(
230 wrapper=friend_model,
231 steps=steps,
232 save_handler=lambda m: self.friend_bridge.update(m),
233 process_name=f"{label}_friend_{friend_id}",
234 )
236 # ========================================================================
237 # Step Handlers
238 # ========================================================================
240 def _verify_friend(self, friend_model: FriendWrapper) -> AppResult[FriendWrapper]:
241 """Verify friend record exists."""
242 return self._get_friend(friend_model)
244 def _send_notifications(
245 self,
246 friend_model: FriendWrapper,
247 is_update: bool,
248 ) -> AppResult[NotificationWrapper]:
249 """Send app notifications for friend action."""
250 friend_id = friend_model.doc_id
251 main_result = AppResult[NotificationWrapper](doc_id=friend_id)
253 context_result = self._get_friend_context(friend_model)
254 if context_result.is_error:
255 main_result.merge(context_result)
256 return main_result
258 context = context_result.generated
259 assert context
261 try:
262 self.notification_service.send_friend_notif(context, is_update=is_update)
263 except Exception as e:
264 msg = f"Exception sending notification for friend {friend_id}: {e}"
265 main_result.add_error(AppErrorCode.NOTIFICATION, msg)
267 return main_result
269 def _create_summary_entry(
270 self,
271 friend_obj: FriendWrapper | str,
272 ) -> AppResult[UserSummaryEntryWrapper]:
273 """Create email summary entry for friend action."""
274 doc_id = friend_obj.doc_id if PersistedGuard.is_friend(friend_obj) else NO_DOC_ID
275 main_result = AppResult[UserSummaryEntryWrapper](doc_id=doc_id)
277 friend_result = self._get_friend(friend_obj)
278 if friend_result.is_error:
279 main_result.merge(friend_result)
280 return main_result
282 friend = friend_result.generated
283 assert friend
284 assert friend.doc_id
286 context_result = self._get_friend_context(friend)
287 if context_result.is_error:
288 main_result.merge(context_result)
289 return main_result
291 context = context_result.generated
292 assert context
294 try:
295 summary_result = self.summary_service.create_friend_request(context)
296 if summary_result.is_error:
297 main_result.merge(summary_result)
298 else:
299 assert summary_result.generated
300 main_result.generated = summary_result.generated
301 except Exception as e:
302 cause = f"Exception creating summary entry for friend {friend.doc_id}: {e}"
303 main_result.add_error(AppErrorCode.DATABASE_EX, cause)
305 return main_result
307 def _update_search_for_friend(
308 self,
309 friend: FriendWrapper,
310 context: FriendContext,
311 ) -> AppResult[FriendWrapper]:
312 """Update or remove friend from search index."""
313 doc_id = friend.doc_id
314 status = friend.status
316 main_result = AppResult[FriendWrapper](doc_id=doc_id)
318 try:
319 if status.remove_from_search:
320 delete_result = self.indexer_service.delete_friend(friend)
321 if delete_result.is_error:
322 main_result.merge(delete_result)
323 else:
324 self.indexer_service.process_friend(context, updated=True)
325 except Exception as e:
326 msg = f"Exception updating search for friend {doc_id}: {e}"
327 main_result.add_error(AppErrorCode.SEARCH, msg)
329 return main_result
331 # ========================================================================
332 # Helper Methods
333 # ========================================================================
335 def _get_friend(self, friend_obj: FriendWrapper | str) -> AppResult[FriendWrapper]:
336 """Get FriendModel from object or ID."""
337 doc_id = friend_obj.doc_id if PersistedGuard.is_friend(friend_obj) else str(friend_obj)
338 main_result: AppResult[FriendWrapper] = AppResult(doc_id=doc_id)
340 if PersistedGuard.is_friend(friend_obj):
341 LOG().debug(f"Using provided FriendModel for friend {friend_obj.doc_id}")
342 main_result.generated = friend_obj
343 return main_result
345 assert isinstance(friend_obj, str) # narrowing
346 LOG().debug(f"Fetching FriendModel for friend id {friend_obj}")
347 friend_id = friend_obj
348 get_result = self.friend_bridge.get(friend_id)
349 if get_result.is_error:
350 main_result.merge(get_result)
352 return main_result
354 def _get_friend_context(self, friend_model: FriendWrapper) -> AppResult[FriendContext]:
355 """Create FriendContext from FriendModel."""
356 doc_id = friend_model.doc_id
357 main_result = AppResult[FriendContext](doc_id=doc_id)
359 try:
360 friend_context = FriendContextFactory().create(friend_model)
361 if friend_context is not None:
362 main_result.generated = friend_context
363 else:
364 cause = f"Failed to build FriendContext for {doc_id}"
365 main_result.add_error(
366 AppErrorCode.CONTEXT,
367 cause,
368 extra=friend_model.to_json_dict(),
369 )
370 except Exception as e:
371 cause = f"Exception creating FriendContext for {doc_id}: {e}"
372 main_result.add_error(
373 AppErrorCode.CONTEXT,
374 cause,
375 extra=friend_model.to_json_dict(),
376 )
378 return main_result