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

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# 

12 

13""" 

14InviteProcessor - Handles all invite-related processing logic. 

15Extracted from FriendAdmin for better testability and maintainability. 

16""" 

17 

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) 

41 

42_K = InviteKeys 

43 

44 

45class InviteProcessor: 

46 """Handles invite workflow processing with automatic state management.""" 

47 

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. 

58 

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 

64 

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 

71 

72 def process_new_invite(self, invite: InviteWrapper) -> JobResult[InviteWrapper]: 

73 """ 

74 Process a new invite (Step 1: Initial). 

75 

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) 

86 

87 # Use StepProcessor for the workflow 

88 

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 ] 

103 

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 ) 

110 

111 result = processor.execute() 

112 

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) 

124 

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] = {} 

129 

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 

136 

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) 

142 

143 return AppResult[FriendWrapper].skip(doc_id=shared_data.get("doc_id", NO_DOC_ID)) 

144 

145 # Entry point 

146 invite_id = invite.doc_id 

147 

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) 

153 

154 return JobResult.skip_doc(doc_id=invite_id, message=msg) 

155 

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 ] 

170 

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 ) 

177 

178 result = processor.execute() 

179 

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) 

187 

188 # ======================================================================== 

189 # CRON Jobs 

190 # ======================================================================== 

191 

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

197 

198 main_result = AppResult[InviteWrapper](doc_id=doc_id) 

199 

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) 

210 

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

220 

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 ) 

232 

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 ) 

251 

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) 

258 

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) 

263 

264 # ======================================================================== 

265 # Step Handlers 

266 # ======================================================================== 

267 

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) 

272 

273 pin_code = CodeGenerator.instance().signup_code() 

274 to_email = invite.to_email 

275 to_name = invite.to_name 

276 

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 ) 

284 

285 user_result = self.user_bridge.create(model) 

286 if user_result.is_error: 

287 main_result.merge(user_result) 

288 

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) 

293 

294 return main_result 

295 

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 

308 

309 invite = invite_result.generated 

310 assert invite is not None # narrowing 

311 

312 invite_id = invite.doc_id 

313 main_result.doc_id = invite_id 

314 

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 

323 

324 invited_user = user_result.generated 

325 assert invited_user 

326 

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 

336 

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 

349 

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 

367 

368 return main_result 

369 

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

377 

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 

390 

391 saved_friend = friend_result.generated 

392 assert isinstance(saved_friend, FriendWrapper) # narrowing 

393 main_result.generated = saved_friend 

394 

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) 

399 

400 return main_result 

401 

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 

408 

409 main_result = AppResult[FriendWrapper]() 

410 if friend_result.is_error: 

411 main_result.merge(friend_result) 

412 return main_result 

413 

414 friend = friend_result.generated 

415 assert friend 

416 

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 

426 

427 self.indexer_service.process_friend(friend_context, updated=True) 

428 

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) 

433 

434 return main_result 

435 

436 # ======================================================================== 

437 # Helper Methods 

438 # ======================================================================== 

439 

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) 

444 

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 

449 

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) 

456 

457 return main_result