Coverage for services/invite_service.py: 43.13%

264 statements  

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

1""" 

2Invite Service for Missing Table 

3Handles invite code generation, validation, and management 

4""" 

5 

6import logging 

7import secrets 

8from datetime import UTC, datetime, timedelta 

9 

10from dao.base_dao import clear_cache 

11from supabase import Client 

12 

13logger = logging.getLogger(__name__) 

14 

15 

16class InviteService: 

17 """Service for managing invitations""" 

18 

19 def __init__(self, supabase_client: Client): 

20 self.supabase = supabase_client 

21 # Characters for invite codes (avoiding ambiguous characters) 

22 self.code_chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" 

23 

24 def generate_invite_code(self) -> str: 

25 """Generate a unique 12-character invite code""" 

26 max_attempts = 100 

27 

28 for _ in range(max_attempts): 

29 # Generate random code 

30 code = "".join(secrets.choice(self.code_chars) for _ in range(12)) 

31 

32 # Check if code already exists 

33 response = self.supabase.table("invitations").select("id").eq("invite_code", code).execute() 

34 

35 if not response.data: 

36 return code 

37 

38 raise Exception("Could not generate unique invite code after 100 attempts") 

39 

40 def create_invitation( 

41 self, 

42 invited_by_user_id: str, 

43 invite_type: str, 

44 team_id: int | None = None, 

45 age_group_id: int | None = None, 

46 club_id: int | None = None, 

47 email: str | None = None, 

48 player_id: int | None = None, 

49 jersey_number: int | None = None, 

50 expires_in_days: int = 7, 

51 ) -> dict: 

52 """ 

53 Create a new invitation 

54 

55 Args: 

56 invited_by_user_id: ID of the user creating the invite 

57 invite_type: Type of invite ('club_manager', 'club_fan', 'team_manager', 'team_player', 'team_fan') 

58 team_id: ID of the team (required for team-level invite types) 

59 age_group_id: ID of the age group (required for team-level invite types) 

60 club_id: ID of the club (required for club_manager and club_fan invite types) 

61 email: Optional email to pre-fill during registration 

62 player_id: Optional roster entry ID (for team_player invites - links account to existing roster) 

63 jersey_number: Optional jersey number (for team_player invites - creates roster on redemption) 

64 expires_in_days: Number of days until invite expires 

65 

66 Returns: 

67 Created invitation record 

68 """ 

69 try: 

70 # Validate parameters based on invite type 

71 if invite_type in ("club_manager", "club_fan"): 

72 if not club_id: 

73 raise ValueError(f"club_id is required for {invite_type} invites") 

74 # Club-level invites don't need team_id or age_group_id 

75 team_id = None 

76 age_group_id = None 

77 player_id = None # No roster linking for club-level invites 

78 jersey_number = None 

79 else: 

80 if not team_id: 

81 raise ValueError("team_id is required for team-level invites") 

82 if not age_group_id: 

83 raise ValueError("age_group_id is required for team-level invites") 

84 # Derive club_id from the team's parent club 

85 club_id = self._get_team_club_id(team_id) 

86 

87 # Generate unique invite code 

88 invite_code = self.generate_invite_code() 

89 

90 # Calculate expiration date 

91 expires_at = datetime.now(UTC) + timedelta(days=expires_in_days) 

92 

93 # Create invitation record 

94 invitation_data = { 

95 "invite_code": invite_code, 

96 "invited_by_user_id": invited_by_user_id, 

97 "invite_type": invite_type, 

98 "team_id": team_id, 

99 "age_group_id": age_group_id, 

100 "club_id": club_id, 

101 "email": email, 

102 "player_id": player_id, 

103 "jersey_number": jersey_number, 

104 "status": "pending", 

105 "expires_at": expires_at.isoformat(), 

106 } 

107 

108 response = self.supabase.table("invitations").insert(invitation_data).execute() 

109 

110 if response.data: 110 ↛ 119line 110 didn't jump to line 119 because the condition on line 110 was always true

111 log_msg = f"Created {invite_type} invitation: {invite_code}" 

112 if player_id: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true

113 log_msg += f" linked to player {player_id}" 

114 elif jersey_number: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true

115 log_msg += f" with jersey #{jersey_number}" 

116 logger.info(log_msg) 

117 return response.data[0] 

118 else: 

119 raise Exception("Failed to create invitation") 

120 

121 except Exception as e: 

122 logger.error(f"Error creating invitation: {e}") 

123 raise 

124 

125 def validate_invite_code(self, code: str) -> dict | None: 

126 """ 

127 Validate an invite code 

128 

129 Args: 

130 code: The invite code to validate 

131 

132 Returns: 

133 Invitation details if valid, None otherwise 

134 """ 

135 try: 

136 logger.info(f"Validating invite code: {code}") 

137 

138 # Get invitation by code with related data including player roster entry 

139 response = ( 

140 self.supabase.table("invitations") 

141 .select( 

142 "*, teams(name), age_groups(name), clubs(name), players(id, jersey_number, first_name, last_name)" 

143 ) 

144 .eq("invite_code", code) 

145 .execute() 

146 ) 

147 

148 if not response.data or len(response.data) == 0: 

149 logger.warning(f"Invite code {code} not found in database") 

150 return None 

151 

152 invitation = response.data[0] 

153 logger.info(f"Found invitation: status={invitation['status']}, expires_at={invitation['expires_at']}") 

154 

155 # Check if already used 

156 if invitation["status"] != "pending": 

157 logger.warning(f"Invite code {code} has status '{invitation['status']}' (not pending)") 

158 return None 

159 

160 # Check if expired 

161 expires_at_str = invitation["expires_at"] 

162 if expires_at_str.endswith("Z"): 

163 expires_at_str = expires_at_str.replace("Z", "+00:00") 

164 expires_at = datetime.fromisoformat(expires_at_str) 

165 

166 # Make current time timezone-aware for comparison 

167 current_time = datetime.now(UTC) 

168 

169 is_expired = expires_at < current_time 

170 logger.info(f"Invite code {code}: expires_at={expires_at}, current={current_time}, is_expired={is_expired}") 

171 

172 if expires_at < current_time: 

173 # Update status to expired 

174 self.supabase.table("invitations").update({"status": "expired"}).eq("id", invitation["id"]).execute() 

175 logger.warning(f"Invite code {code} expired at {expires_at}") 

176 return None 

177 

178 # Build player info if linked to roster 

179 player_info = None 

180 if invitation.get("players"): 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 player = invitation["players"] 

182 player_info = { 

183 "id": player["id"], 

184 "jersey_number": player["jersey_number"], 

185 "first_name": player.get("first_name"), 

186 "last_name": player.get("last_name"), 

187 } 

188 

189 logger.info(f"Invite code {code} is valid!") 

190 return { 

191 "valid": True, 

192 "id": invitation["id"], 

193 "invite_type": invitation["invite_type"], 

194 "team_id": invitation["team_id"], 

195 "team_name": invitation["teams"]["name"] if invitation.get("teams") else None, 

196 "age_group_id": invitation["age_group_id"], 

197 "age_group_name": invitation["age_groups"]["name"] if invitation.get("age_groups") else None, 

198 "club_id": invitation.get("club_id"), 

199 "club_name": invitation["clubs"]["name"] if invitation.get("clubs") else None, 

200 "email": invitation["email"], 

201 "invited_by_user_id": invitation.get("invited_by_user_id"), 

202 "player_id": invitation.get("player_id"), 

203 "jersey_number": invitation.get("jersey_number"), 

204 "player": player_info, 

205 } 

206 

207 except Exception as e: 

208 logger.error(f"Error validating invite code {code}: {e}", exc_info=True) 

209 return None 

210 

211 def redeem_invitation(self, code: str, user_id: str) -> bool: 

212 """ 

213 Redeem an invitation code 

214 

215 Args: 

216 code: The invite code to redeem 

217 user_id: ID of the user redeeming the code 

218 

219 Returns: 

220 True if successful, False otherwise 

221 """ 

222 try: 

223 # First validate the code 

224 invitation = self.validate_invite_code(code) 

225 if not invitation: 

226 return False 

227 

228 # Update invitation status 

229 response = ( 

230 self.supabase.table("invitations") 

231 .update( 

232 { 

233 "status": "used", 

234 "used_at": datetime.now(UTC).isoformat(), 

235 "used_by_user_id": user_id, 

236 } 

237 ) 

238 .eq("invite_code", code) 

239 .execute() 

240 ) 

241 

242 if response.data: 242 ↛ 289line 242 didn't jump to line 289 because the condition on line 242 was always true

243 logger.info(f"Invitation {code} redeemed by user {user_id}") 

244 

245 # Create team_manager_assignments entry for team_manager invites 

246 # This enables the team manager to create player/fan invites for their team 

247 if invitation.get("invite_type") == "team_manager": 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true

248 self._create_team_manager_assignment( 

249 user_id=user_id, 

250 team_id=invitation.get("team_id"), 

251 age_group_id=invitation.get("age_group_id"), 

252 assigned_by_user_id=invitation.get("invited_by_user_id"), 

253 ) 

254 

255 # Handle player roster linking for team_player invites 

256 if invitation.get("invite_type") == "team_player": 256 ↛ 287line 256 didn't jump to line 287 because the condition on line 256 was always true

257 logger.info( 

258 f"Processing team_player invite: " 

259 f"player_id={invitation.get('player_id')}, " 

260 f"jersey={invitation.get('jersey_number')}, " 

261 f"team={invitation.get('team_id')}, " 

262 f"age_group={invitation.get('age_group_id')}" 

263 ) 

264 if invitation.get("player_id"): 264 ↛ 266line 264 didn't jump to line 266 because the condition on line 264 was never true

265 # Link user to existing roster entry (also creates player_team_history) 

266 self._link_user_to_roster_entry( 

267 user_id=user_id, 

268 player_id=invitation.get("player_id"), 

269 team_id=invitation.get("team_id"), 

270 age_group_id=invitation.get("age_group_id"), 

271 ) 

272 elif invitation.get("jersey_number"): 272 ↛ 275line 272 didn't jump to line 275 because the condition on line 272 was never true

273 # Create new roster entry with jersey number and link user 

274 # Also creates player_team_history entry 

275 jersey = invitation.get("jersey_number") 

276 logger.info(f"Creating roster entry for jersey #{jersey}") 

277 self._create_and_link_roster_entry( 

278 user_id=user_id, 

279 team_id=invitation.get("team_id"), 

280 jersey_number=invitation.get("jersey_number"), 

281 age_group_id=invitation.get("age_group_id"), 

282 invited_by_user_id=invitation.get("invited_by_user_id"), 

283 ) 

284 else: 

285 logger.info("No player_id or jersey_number on invite - skipping roster") 

286 

287 return True 

288 

289 return False 

290 

291 except Exception as e: 

292 logger.error(f"Error redeeming invitation {code}: {e}") 

293 return False 

294 

295 def _get_team_club_id(self, team_id: int) -> int | None: 

296 """Look up a team's parent club_id.""" 

297 try: 

298 response = self.supabase.table("teams").select("club_id").eq("id", team_id).execute() 

299 if response.data: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true

300 return response.data[0].get("club_id") 

301 except Exception as e: 

302 logger.warning(f"Could not look up club_id for team {team_id}: {e}") 

303 return None 

304 

305 def _link_user_to_roster_entry( 

306 self, 

307 user_id: str, 

308 player_id: int, 

309 team_id: int | None = None, 

310 season_id: int | None = None, 

311 age_group_id: int | None = None, 

312 league_id: int | None = None, 

313 division_id: int | None = None, 

314 jersey_number: int | None = None, 

315 ) -> bool: 

316 """ 

317 Link a user account to a roster entry in the players table. 

318 Also creates a player_team_history entry for the UI roster view. 

319 

320 Called when a player accepts an invitation that was tied to a roster entry. 

321 

322 Args: 

323 user_id: The user ID to link 

324 player_id: The roster entry ID to link to 

325 team_id: Team ID (optional, fetched from roster if not provided) 

326 season_id: Season ID (optional, fetched from roster if not provided) 

327 age_group_id: Age group ID from the invite 

328 league_id: League ID (optional, fetched from team if not provided) 

329 division_id: Division ID (optional, fetched from team if not provided) 

330 jersey_number: Jersey number (optional, fetched from roster if not provided) 

331 

332 Returns: 

333 True if successful, False otherwise 

334 """ 

335 try: 

336 # If we don't have the roster details, fetch them 

337 if team_id is None or season_id is None or jersey_number is None: 

338 roster_response = ( 

339 self.supabase.table("players") 

340 .select("team_id, season_id, jersey_number") 

341 .eq("id", player_id) 

342 .limit(1) 

343 .execute() 

344 ) 

345 

346 if roster_response.data: 

347 roster = roster_response.data[0] 

348 team_id = team_id or roster.get("team_id") 

349 season_id = season_id or roster.get("season_id") 

350 jersey_number = jersey_number or roster.get("jersey_number") 

351 

352 # If we don't have league/division, fetch from team 

353 if team_id and (league_id is None or division_id is None): 

354 team_response = ( 

355 self.supabase.table("teams").select("league_id, division_id").eq("id", team_id).limit(1).execute() 

356 ) 

357 

358 if team_response.data: 

359 team = team_response.data[0] 

360 league_id = league_id or team.get("league_id") 

361 division_id = division_id or team.get("division_id") 

362 

363 # Get age_group_id from team_mappings if not provided by the invite 

364 if team_id and age_group_id is None: 

365 mapping_response = ( 

366 self.supabase.table("team_mappings") 

367 .select("age_group_id") 

368 .eq("team_id", team_id) 

369 .limit(1) 

370 .execute() 

371 ) 

372 if mapping_response.data: 

373 age_group_id = mapping_response.data[0]["age_group_id"] 

374 

375 # Update the players table to link user_profile_id 

376 response = self.supabase.table("players").update({"user_profile_id": user_id}).eq("id", player_id).execute() 

377 

378 if response.data: 

379 logger.info(f"Linked user {user_id} to roster entry {player_id}") 

380 

381 # Also create player_team_history entry for UI roster view 

382 if team_id and season_id: 

383 self._create_player_team_history( 

384 user_id=user_id, 

385 team_id=team_id, 

386 season_id=season_id, 

387 age_group_id=age_group_id, 

388 league_id=league_id, 

389 division_id=division_id, 

390 jersey_number=jersey_number, 

391 ) 

392 

393 # Invalidate caches 

394 clear_cache("mt:dao:roster:*") 

395 clear_cache("mt:dao:players:*") 

396 return True 

397 

398 logger.warning(f"Failed to link user {user_id} to roster entry {player_id}") 

399 return False 

400 

401 except Exception as e: 

402 logger.error(f"Error linking user to roster entry: {e}") 

403 return False 

404 

405 def _create_and_link_roster_entry( 

406 self, 

407 user_id: str, 

408 team_id: int, 

409 jersey_number: int, 

410 age_group_id: int | None = None, 

411 invited_by_user_id: str | None = None, 

412 ) -> bool: 

413 """ 

414 Create a new roster entry and link a user account to it, or link to 

415 an existing roster entry if one already exists with that jersey number. 

416 Also creates a player_team_history entry for the UI roster view. 

417 

418 Called when a player accepts an invitation that included a jersey number 

419 but no existing player_id. 

420 

421 Args: 

422 user_id: The user ID to link 

423 team_id: The team ID for the roster entry 

424 jersey_number: The jersey number for the roster entry 

425 age_group_id: The age group ID from the invite 

426 invited_by_user_id: The user who created the invite (for created_by field) 

427 

428 Returns: 

429 True if successful, False otherwise 

430 """ 

431 try: 

432 # Get the current season (based on today's date being within start_date and end_date) 

433 from datetime import date 

434 

435 today = date.today().isoformat() 

436 

437 season_response = ( 

438 self.supabase.table("seasons") 

439 .select("id") 

440 .lte("start_date", today) 

441 .gte("end_date", today) 

442 .limit(1) 

443 .execute() 

444 ) 

445 

446 if not season_response.data: 

447 logger.error("No current season found - cannot create roster entry") 

448 return False 

449 

450 season_id = season_response.data[0]["id"] 

451 

452 # Get team details for league_id and division_id 

453 team_response = ( 

454 self.supabase.table("teams").select("league_id, division_id").eq("id", team_id).limit(1).execute() 

455 ) 

456 

457 league_id = None 

458 division_id = None 

459 if team_response.data: 

460 league_id = team_response.data[0].get("league_id") 

461 division_id = team_response.data[0].get("division_id") 

462 

463 # Get age_group_id from team_mappings if not provided by the invite 

464 if age_group_id is None: 

465 mapping_response = ( 

466 self.supabase.table("team_mappings") 

467 .select("age_group_id") 

468 .eq("team_id", team_id) 

469 .limit(1) 

470 .execute() 

471 ) 

472 if mapping_response.data: 

473 age_group_id = mapping_response.data[0]["age_group_id"] 

474 

475 # Check if a player with this jersey number already exists 

476 existing_response = ( 

477 self.supabase.table("players") 

478 .select("id, user_profile_id") 

479 .eq("team_id", team_id) 

480 .eq("season_id", season_id) 

481 .eq("jersey_number", jersey_number) 

482 .limit(1) 

483 .execute() 

484 ) 

485 

486 if existing_response.data: 

487 # Player exists - link user to existing roster entry 

488 existing_player = existing_response.data[0] 

489 player_id = existing_player["id"] 

490 

491 if existing_player.get("user_profile_id"): 

492 logger.warning( 

493 f"Roster entry {player_id} (jersey #{jersey_number}) " 

494 f"already linked to user {existing_player['user_profile_id']}" 

495 ) 

496 return False 

497 

498 # Link user to existing roster entry (will also create player_team_history) 

499 return self._link_user_to_roster_entry( 

500 user_id=user_id, 

501 player_id=player_id, 

502 team_id=team_id, 

503 season_id=season_id, 

504 age_group_id=age_group_id, 

505 league_id=league_id, 

506 division_id=division_id, 

507 jersey_number=jersey_number, 

508 ) 

509 

510 # Create new roster entry with user already linked 

511 player_data = { 

512 "team_id": team_id, 

513 "season_id": season_id, 

514 "jersey_number": jersey_number, 

515 "user_profile_id": user_id, 

516 "is_active": True, 

517 } 

518 

519 if invited_by_user_id: 

520 player_data["created_by"] = invited_by_user_id 

521 

522 response = self.supabase.table("players").insert(player_data).execute() 

523 

524 if response.data: 

525 player_id = response.data[0]["id"] 

526 logger.info( 

527 f"Created roster entry {player_id} with jersey #{jersey_number} " 

528 f"for user {user_id} on team {team_id}" 

529 ) 

530 

531 # Also create player_team_history entry for UI roster view 

532 self._create_player_team_history( 

533 user_id=user_id, 

534 team_id=team_id, 

535 season_id=season_id, 

536 age_group_id=age_group_id, 

537 league_id=league_id, 

538 division_id=division_id, 

539 jersey_number=jersey_number, 

540 ) 

541 

542 # Invalidate caches 

543 clear_cache("mt:dao:roster:*") 

544 clear_cache("mt:dao:players:*") 

545 return True 

546 

547 logger.warning(f"Failed to create roster entry for user {user_id}") 

548 return False 

549 

550 except Exception as e: 

551 logger.error(f"Error creating and linking roster entry: {e}") 

552 return False 

553 

554 def _create_player_team_history( 

555 self, 

556 user_id: str, 

557 team_id: int, 

558 season_id: int, 

559 age_group_id: int | None = None, 

560 league_id: int | None = None, 

561 division_id: int | None = None, 

562 jersey_number: int | None = None, 

563 positions: list[str] | None = None, 

564 ) -> bool: 

565 """ 

566 Create a player_team_history entry for the UI roster view. 

567 

568 This is the primary table used by the UI to display team rosters. 

569 Called when a player accepts an invitation. 

570 

571 Args: 

572 user_id: The user ID (player_id in the table) 

573 team_id: Team ID 

574 season_id: Season ID 

575 age_group_id: Age group ID 

576 league_id: League ID 

577 division_id: Division ID 

578 jersey_number: Jersey number 

579 positions: List of positions (e.g., ['MF', 'FW']) 

580 

581 Returns: 

582 True if successful, False otherwise 

583 """ 

584 try: 

585 # Check if entry already exists for this player/team/season 

586 existing = ( 

587 self.supabase.table("player_team_history") 

588 .select("id") 

589 .eq("player_id", user_id) 

590 .eq("team_id", team_id) 

591 .eq("season_id", season_id) 

592 .limit(1) 

593 .execute() 

594 ) 

595 

596 if existing.data: 

597 logger.info( 

598 f"player_team_history entry already exists for user {user_id}, team {team_id}, season {season_id}" 

599 ) 

600 return True 

601 

602 # Create new entry 

603 history_data = { 

604 "player_id": user_id, 

605 "team_id": team_id, 

606 "season_id": season_id, 

607 "is_current": True, 

608 } 

609 

610 if age_group_id is not None: 

611 history_data["age_group_id"] = age_group_id 

612 if league_id is not None: 

613 history_data["league_id"] = league_id 

614 if division_id is not None: 

615 history_data["division_id"] = division_id 

616 if jersey_number is not None: 

617 history_data["jersey_number"] = jersey_number 

618 if positions: 

619 history_data["positions"] = positions 

620 

621 response = self.supabase.table("player_team_history").insert(history_data).execute() 

622 

623 if response.data: 

624 history_id = response.data[0]["id"] 

625 logger.info( 

626 f"Created player_team_history entry {history_id} for user {user_id} " 

627 f"on team {team_id}, season {season_id}, jersey #{jersey_number}" 

628 ) 

629 return True 

630 

631 logger.warning(f"Failed to create player_team_history for user {user_id}") 

632 return False 

633 

634 except Exception as e: 

635 logger.error(f"Error creating player_team_history: {e}") 

636 return False 

637 

638 def _create_team_manager_assignment( 

639 self, user_id: str, team_id: int, age_group_id: int, assigned_by_user_id: str 

640 ) -> bool: 

641 """ 

642 Create a team_manager_assignments entry to grant team management permissions. 

643 

644 Args: 

645 user_id: The user being granted management rights 

646 team_id: The team they can manage 

647 age_group_id: The age group (not stored - table doesn't have this column) 

648 assigned_by_user_id: The user who created the invite (not stored) 

649 

650 Returns: 

651 True if successful, False otherwise 

652 """ 

653 try: 

654 # Check if assignment already exists (table has unique constraint on user_id, team_id) 

655 existing = ( 

656 self.supabase.table("team_manager_assignments") 

657 .select("id") 

658 .eq("user_id", user_id) 

659 .eq("team_id", team_id) 

660 .execute() 

661 ) 

662 

663 if existing.data: 

664 logger.info(f"Team manager assignment already exists for user {user_id}, team {team_id}") 

665 return True 

666 

667 # Create new assignment (table schema: id, user_id, team_id, created_at) 

668 response = ( 

669 self.supabase.table("team_manager_assignments") 

670 .insert( 

671 { 

672 "user_id": user_id, 

673 "team_id": team_id, 

674 } 

675 ) 

676 .execute() 

677 ) 

678 

679 if response.data: 

680 logger.info(f"Created team manager assignment: user {user_id} -> team {team_id}") 

681 return True 

682 

683 logger.warning(f"Failed to create team manager assignment for user {user_id}") 

684 return False 

685 

686 except Exception as e: 

687 logger.error(f"Error creating team manager assignment: {e}") 

688 return False 

689 

690 def get_user_invitations(self, user_id: str) -> list[dict]: 

691 """ 

692 Get all invitations created by a user 

693 

694 Args: 

695 user_id: ID of the user 

696 

697 Returns: 

698 List of invitations 

699 """ 

700 try: 

701 response = ( 

702 self.supabase.table("invitations") 

703 .select("*, teams(name), age_groups(name), clubs(name)") 

704 .eq("invited_by_user_id", user_id) 

705 .order("created_at", desc=True) 

706 .execute() 

707 ) 

708 

709 return response.data if response.data else [] 

710 

711 except Exception as e: 

712 logger.error(f"Error getting user invitations: {e}") 

713 return [] 

714 

715 def cancel_invitation(self, invite_id: str, user_id: str) -> bool: 

716 """ 

717 Cancel a pending invitation 

718 

719 Args: 

720 invite_id: ID of the invitation to cancel 

721 user_id: ID of the user cancelling (must be creator or admin) 

722 

723 Returns: 

724 True if successful, False otherwise 

725 """ 

726 try: 

727 # Check if user can cancel this invitation 

728 response = ( 

729 self.supabase.table("invitations") 

730 .select("invited_by_user_id, status") 

731 .eq("id", invite_id) 

732 .single() 

733 .execute() 

734 ) 

735 

736 if not response.data: 

737 return False 

738 

739 invitation = response.data 

740 

741 # Only pending invitations can be cancelled 

742 if invitation["status"] != "pending": 

743 return False 

744 

745 # Update status to expired 

746 response = self.supabase.table("invitations").update({"status": "expired"}).eq("id", invite_id).execute() 

747 

748 if response.data: 748 ↛ 752line 748 didn't jump to line 752 because the condition on line 748 was always true

749 logger.info(f"Invitation {invite_id} cancelled by user {user_id}") 

750 return True 

751 

752 return False 

753 

754 except Exception as e: 

755 logger.error(f"Error cancelling invitation {invite_id}: {e}") 

756 return False 

757 

758 def expire_old_invitations(self) -> int: 

759 """ 

760 Expire all old pending invitations 

761 

762 Returns: 

763 Number of invitations expired 

764 """ 

765 try: 

766 # Get all pending invitations that have expired 

767 response = ( 

768 self.supabase.table("invitations") 

769 .select("id") 

770 .eq("status", "pending") 

771 .lt("expires_at", datetime.now(UTC).isoformat()) 

772 .execute() 

773 ) 

774 

775 if not response.data: 

776 return 0 

777 

778 # Update all to expired 

779 expired_ids = [inv["id"] for inv in response.data] 

780 

781 self.supabase.table("invitations").update({"status": "expired"}).in_("id", expired_ids).execute() 

782 

783 logger.info(f"Expired {len(expired_ids)} old invitations") 

784 return len(expired_ids) 

785 

786 except Exception as e: 

787 logger.error(f"Error expiring old invitations: {e}") 

788 return 0