Coverage for src / idx_api / routers / clients.py: 50%

398 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 11:09 -0700

1"""CRM Client endpoints for managing clients, contacts, and activities.""" 

2 

3from datetime import datetime, timezone 

4from typing import Optional 

5 

6from fastapi import APIRouter, Depends, HTTPException, Query 

7from pydantic import BaseModel 

8from sqlalchemy import func, select 

9from sqlalchemy.orm import Session, selectinload 

10 

11from idx_api.auth import BrokerUser, RequiredUser 

12from idx_api.database import get_db 

13from idx_api.models.client import ( 

14 Client, 

15 ClientActivity, 

16 ClientContact, 

17 ACTIVITY_TYPES, 

18 BUYER_STATUSES, 

19 CLIENT_TYPES, 

20 CONTACT_ROLES, 

21 INTEREST_TYPES, 

22 SELLER_STATUSES, 

23) 

24 

25router = APIRouter() 

26 

27 

28# ===== Response Models ===== 

29 

30 

31class ClientContactResponse(BaseModel): 

32 """Client contact response model.""" 

33 

34 id: int 

35 client_id: int 

36 first_name: str 

37 last_name: str 

38 email: str | None 

39 phone: str | None 

40 phone_display: str | None 

41 role: str 

42 is_primary: bool 

43 address_line1: str | None 

44 address_line2: str | None 

45 city: str | None 

46 state: str | None 

47 zip_code: str | None 

48 disabled_at: datetime | None 

49 created_at: datetime 

50 updated_at: datetime 

51 

52 class Config: 

53 from_attributes = True 

54 

55 

56class ClientActivityResponse(BaseModel): 

57 """Client activity response model.""" 

58 

59 id: int 

60 client_id: int 

61 agent_id: int | None 

62 property_id: str | None 

63 activity_type: str 

64 title: str 

65 description: str | None 

66 activity_at: datetime 

67 outcome: str | None 

68 follow_up_at: datetime | None 

69 created_at: datetime 

70 updated_at: datetime 

71 

72 class Config: 

73 from_attributes = True 

74 

75 

76class ClientResponse(BaseModel): 

77 """Client response model.""" 

78 

79 id: int 

80 brokerage_id: int 

81 assigned_agent_id: int | None 

82 client_type: str 

83 display_name: str 

84 entity_name: str | None 

85 interest_type: str 

86 buyer_status: str | None 

87 seller_status: str | None 

88 budget_min: int | None 

89 budget_max: int | None 

90 beds_min: int | None 

91 baths_min: float | None 

92 property_types: str | None 

93 preferred_areas: str | None 

94 listing_address: str | None 

95 expected_price: int | None 

96 listing_timeline: str | None 

97 source: str | None 

98 referral_name: str | None 

99 tags: str | None 

100 notes: str | None 

101 first_contact_at: datetime | None 

102 last_contact_at: datetime | None 

103 disabled_at: datetime | None 

104 created_at: datetime 

105 updated_at: datetime 

106 

107 class Config: 

108 from_attributes = True 

109 

110 

111class ClientDetailResponse(ClientResponse): 

112 """Client response with nested contacts.""" 

113 

114 contacts: list[ClientContactResponse] = [] 

115 

116 

117# ===== Request Models ===== 

118 

119 

120class ClientCreate(BaseModel): 

121 """Client creation request.""" 

122 

123 brokerage_id: int 

124 assigned_agent_id: int | None = None 

125 client_type: str = "person" 

126 display_name: str 

127 entity_name: str | None = None 

128 interest_type: str = "buyer" 

129 buyer_status: str | None = "lead" 

130 seller_status: str | None = None 

131 budget_min: int | None = None 

132 budget_max: int | None = None 

133 beds_min: int | None = None 

134 baths_min: float | None = None 

135 property_types: str | None = None 

136 preferred_areas: str | None = None 

137 listing_address: str | None = None 

138 expected_price: int | None = None 

139 listing_timeline: str | None = None 

140 source: str | None = None 

141 referral_name: str | None = None 

142 tags: str | None = None 

143 notes: str | None = None 

144 first_contact_at: datetime | None = None 

145 

146 

147class ClientUpdate(BaseModel): 

148 """Client update request.""" 

149 

150 assigned_agent_id: int | None = None 

151 client_type: str | None = None 

152 display_name: str | None = None 

153 entity_name: str | None = None 

154 interest_type: str | None = None 

155 buyer_status: str | None = None 

156 seller_status: str | None = None 

157 budget_min: int | None = None 

158 budget_max: int | None = None 

159 beds_min: int | None = None 

160 baths_min: float | None = None 

161 property_types: str | None = None 

162 preferred_areas: str | None = None 

163 listing_address: str | None = None 

164 expected_price: int | None = None 

165 listing_timeline: str | None = None 

166 source: str | None = None 

167 referral_name: str | None = None 

168 tags: str | None = None 

169 notes: str | None = None 

170 

171 

172class ClientContactCreate(BaseModel): 

173 """Client contact creation request.""" 

174 

175 first_name: str 

176 last_name: str 

177 email: str | None = None 

178 phone: str | None = None 

179 phone_display: str | None = None 

180 role: str = "primary" 

181 is_primary: bool = True 

182 address_line1: str | None = None 

183 address_line2: str | None = None 

184 city: str | None = None 

185 state: str | None = None 

186 zip_code: str | None = None 

187 

188 

189class ClientContactUpdate(BaseModel): 

190 """Client contact update request.""" 

191 

192 first_name: str | None = None 

193 last_name: str | None = None 

194 email: str | None = None 

195 phone: str | None = None 

196 phone_display: str | None = None 

197 role: str | None = None 

198 is_primary: bool | None = None 

199 address_line1: str | None = None 

200 address_line2: str | None = None 

201 city: str | None = None 

202 state: str | None = None 

203 zip_code: str | None = None 

204 

205 

206class ClientActivityCreate(BaseModel): 

207 """Client activity creation request.""" 

208 

209 agent_id: int | None = None 

210 property_id: str | None = None 

211 activity_type: str = "note" 

212 title: str 

213 description: str | None = None 

214 activity_at: datetime 

215 outcome: str | None = None 

216 follow_up_at: datetime | None = None 

217 

218 

219class ClientActivityUpdate(BaseModel): 

220 """Client activity update request.""" 

221 

222 agent_id: int | None = None 

223 property_id: str | None = None 

224 activity_type: str | None = None 

225 title: str | None = None 

226 description: str | None = None 

227 activity_at: datetime | None = None 

228 outcome: str | None = None 

229 follow_up_at: datetime | None = None 

230 

231 

232# ===== Helper Functions ===== 

233 

234 

235def _now() -> datetime: 

236 return datetime.now(timezone.utc) 

237 

238 

239def _validate_client_type(client_type: str) -> None: 

240 if client_type not in CLIENT_TYPES: 

241 raise HTTPException( 

242 status_code=400, 

243 detail=f"Invalid client_type. Must be one of: {', '.join(CLIENT_TYPES)}", 

244 ) 

245 

246 

247def _validate_interest_type(interest_type: str) -> None: 

248 if interest_type not in INTEREST_TYPES: 

249 raise HTTPException( 

250 status_code=400, 

251 detail=f"Invalid interest_type. Must be one of: {', '.join(INTEREST_TYPES)}", 

252 ) 

253 

254 

255def _validate_buyer_status(status: str | None) -> None: 

256 if status and status not in BUYER_STATUSES: 

257 raise HTTPException( 

258 status_code=400, 

259 detail=f"Invalid buyer_status. Must be one of: {', '.join(BUYER_STATUSES)}", 

260 ) 

261 

262 

263def _validate_seller_status(status: str | None) -> None: 

264 if status and status not in SELLER_STATUSES: 

265 raise HTTPException( 

266 status_code=400, 

267 detail=f"Invalid seller_status. Must be one of: {', '.join(SELLER_STATUSES)}", 

268 ) 

269 

270 

271def _validate_contact_role(role: str) -> None: 

272 if role not in CONTACT_ROLES: 

273 raise HTTPException( 

274 status_code=400, 

275 detail=f"Invalid role. Must be one of: {', '.join(CONTACT_ROLES)}", 

276 ) 

277 

278 

279def _validate_activity_type(activity_type: str) -> None: 

280 if activity_type not in ACTIVITY_TYPES: 

281 raise HTTPException( 

282 status_code=400, 

283 detail=f"Invalid activity_type. Must be one of: {', '.join(ACTIVITY_TYPES)}", 

284 ) 

285 

286 

287# ===== Client CRUD Endpoints ===== 

288 

289 

290# NOTE: Static routes must be defined BEFORE parameterized routes like /clients/{client_id} 

291@router.get("/clients/enums") 

292async def get_client_enums(): 

293 """Get all valid enum values for client fields.""" 

294 return { 

295 "client_types": CLIENT_TYPES, 

296 "interest_types": INTEREST_TYPES, 

297 "buyer_statuses": BUYER_STATUSES, 

298 "seller_statuses": SELLER_STATUSES, 

299 "contact_roles": CONTACT_ROLES, 

300 "activity_types": ACTIVITY_TYPES, 

301 } 

302 

303 

304@router.get("/clients") 

305async def list_clients( 

306 user: RequiredUser, 

307 db: Session = Depends(get_db), 

308 brokerage_id: int | None = Query(None, description="Filter by brokerage ID"), 

309 assigned_agent_id: int | None = Query(None, description="Filter by assigned agent"), 

310 interest_type: str | None = Query(None, description="Filter by interest type"), 

311 buyer_status: str | None = Query(None, description="Filter by buyer status"), 

312 seller_status: str | None = Query(None, description="Filter by seller status"), 

313 search: str | None = Query(None, description="Search by display name"), 

314 page: int = Query(1, ge=1), 

315 page_size: int = Query(20, ge=1, le=100), 

316): 

317 """ 

318 List clients with pagination and filters. 

319 

320 - Admins see all clients 

321 - Non-admins see only clients in their brokerage 

322 """ 

323 base_where = [Client.disabled_at.is_(None)] 

324 

325 # Brokerage filter 

326 if brokerage_id: 

327 base_where.append(Client.brokerage_id == brokerage_id) 

328 elif user.role != "admin" and user.brokerage_id: 

329 base_where.append(Client.brokerage_id == user.brokerage_id) 

330 

331 # Additional filters 

332 if assigned_agent_id: 

333 base_where.append(Client.assigned_agent_id == assigned_agent_id) 

334 if interest_type: 

335 base_where.append(Client.interest_type == interest_type) 

336 if buyer_status: 

337 base_where.append(Client.buyer_status == buyer_status) 

338 if seller_status: 

339 base_where.append(Client.seller_status == seller_status) 

340 if search: 

341 base_where.append(Client.display_name.ilike(f"%{search}%")) 

342 

343 # Count total 

344 total = db.scalar(select(func.count()).select_from(Client).where(*base_where)) 

345 

346 # Get paginated results 

347 offset = (page - 1) * page_size 

348 clients = db.scalars( 

349 select(Client) 

350 .where(*base_where) 

351 .order_by(Client.last_contact_at.desc().nullslast(), Client.created_at.desc()) 

352 .offset(offset) 

353 .limit(page_size) 

354 ).all() 

355 

356 total_pages = (total + page_size - 1) // page_size 

357 

358 return { 

359 "items": [ClientResponse.model_validate(c) for c in clients], 

360 "total": total, 

361 "page": page, 

362 "page_size": page_size, 

363 "total_pages": total_pages, 

364 } 

365 

366 

367@router.get("/clients/{client_id}", response_model=ClientDetailResponse) 

368async def get_client( 

369 client_id: int, 

370 user: RequiredUser, 

371 db: Session = Depends(get_db), 

372): 

373 """Get a single client by ID with contacts.""" 

374 client = db.scalars( 

375 select(Client) 

376 .where(Client.id == client_id) 

377 .options(selectinload(Client.contacts)) 

378 ).first() 

379 

380 if not client or client.disabled_at: 

381 raise HTTPException(status_code=404, detail="Client not found") 

382 

383 # Authorization check 

384 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

385 raise HTTPException(status_code=403, detail="Access denied") 

386 

387 # Filter out disabled contacts 

388 active_contacts = [c for c in client.contacts if not c.disabled_at] 

389 

390 response = ClientDetailResponse.model_validate(client) 

391 response.contacts = [ClientContactResponse.model_validate(c) for c in active_contacts] 

392 

393 return response 

394 

395 

396@router.post("/clients", response_model=ClientResponse) 

397async def create_client( 

398 data: ClientCreate, 

399 user: BrokerUser, 

400 db: Session = Depends(get_db), 

401): 

402 """Create a new client.""" 

403 # Authorization 

404 if user.role != "admin" and user.brokerage_id != data.brokerage_id: 

405 raise HTTPException( 

406 status_code=403, detail="Can only create clients for your own brokerage" 

407 ) 

408 

409 # Validation 

410 _validate_client_type(data.client_type) 

411 _validate_interest_type(data.interest_type) 

412 _validate_buyer_status(data.buyer_status) 

413 _validate_seller_status(data.seller_status) 

414 

415 now = _now() 

416 client = Client( 

417 **data.model_dump(), 

418 first_contact_at=data.first_contact_at or now, 

419 last_contact_at=now, 

420 created_at=now, 

421 updated_at=now, 

422 ) 

423 

424 db.add(client) 

425 db.commit() 

426 db.refresh(client) 

427 

428 return ClientResponse.model_validate(client) 

429 

430 

431@router.put("/clients/{client_id}", response_model=ClientResponse) 

432async def update_client( 

433 client_id: int, 

434 data: ClientUpdate, 

435 user: BrokerUser, 

436 db: Session = Depends(get_db), 

437): 

438 """Update an existing client.""" 

439 client = db.get(Client, client_id) 

440 if not client or client.disabled_at: 

441 raise HTTPException(status_code=404, detail="Client not found") 

442 

443 # Authorization 

444 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

445 raise HTTPException(status_code=403, detail="Access denied") 

446 

447 # Validation 

448 update_data = data.model_dump(exclude_unset=True) 

449 if "client_type" in update_data: 

450 _validate_client_type(update_data["client_type"]) 

451 if "interest_type" in update_data: 

452 _validate_interest_type(update_data["interest_type"]) 

453 if "buyer_status" in update_data: 

454 _validate_buyer_status(update_data["buyer_status"]) 

455 if "seller_status" in update_data: 

456 _validate_seller_status(update_data["seller_status"]) 

457 

458 for field, value in update_data.items(): 

459 setattr(client, field, value) 

460 

461 client.updated_at = _now() 

462 db.commit() 

463 db.refresh(client) 

464 

465 return ClientResponse.model_validate(client) 

466 

467 

468@router.delete("/clients/{client_id}") 

469async def delete_client( 

470 client_id: int, 

471 user: BrokerUser, 

472 db: Session = Depends(get_db), 

473): 

474 """Soft-delete a client.""" 

475 client = db.get(Client, client_id) 

476 if not client: 

477 raise HTTPException(status_code=404, detail="Client not found") 

478 

479 # Authorization 

480 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

481 raise HTTPException(status_code=403, detail="Access denied") 

482 

483 now = _now() 

484 client.disabled_at = now 

485 client.updated_at = now 

486 db.commit() 

487 

488 return {"message": "Client deleted successfully"} 

489 

490 

491# ===== Client Contact CRUD Endpoints ===== 

492 

493 

494@router.get("/clients/{client_id}/contacts") 

495async def list_client_contacts( 

496 client_id: int, 

497 user: RequiredUser, 

498 db: Session = Depends(get_db), 

499): 

500 """List contacts for a client.""" 

501 client = db.get(Client, client_id) 

502 if not client or client.disabled_at: 

503 raise HTTPException(status_code=404, detail="Client not found") 

504 

505 # Authorization 

506 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

507 raise HTTPException(status_code=403, detail="Access denied") 

508 

509 contacts = db.scalars( 

510 select(ClientContact) 

511 .where(ClientContact.client_id == client_id, ClientContact.disabled_at.is_(None)) 

512 .order_by(ClientContact.is_primary.desc(), ClientContact.created_at.asc()) 

513 ).all() 

514 

515 return {"items": [ClientContactResponse.model_validate(c) for c in contacts]} 

516 

517 

518@router.post("/clients/{client_id}/contacts", response_model=ClientContactResponse) 

519async def create_client_contact( 

520 client_id: int, 

521 data: ClientContactCreate, 

522 user: BrokerUser, 

523 db: Session = Depends(get_db), 

524): 

525 """Create a new contact for a client.""" 

526 client = db.get(Client, client_id) 

527 if not client or client.disabled_at: 

528 raise HTTPException(status_code=404, detail="Client not found") 

529 

530 # Authorization 

531 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

532 raise HTTPException(status_code=403, detail="Access denied") 

533 

534 # Validation 

535 _validate_contact_role(data.role) 

536 

537 # If setting as primary, unset primary for other contacts 

538 if data.is_primary: 

539 existing_primary = db.scalars( 

540 select(ClientContact).where( 

541 ClientContact.client_id == client_id, 

542 ClientContact.is_primary == True, 

543 ClientContact.disabled_at.is_(None), 

544 ) 

545 ).all() 

546 now = _now() 

547 for contact in existing_primary: 

548 contact.is_primary = False 

549 contact.updated_at = now 

550 

551 now = _now() 

552 contact = ClientContact( 

553 client_id=client_id, 

554 **data.model_dump(), 

555 created_at=now, 

556 updated_at=now, 

557 ) 

558 

559 db.add(contact) 

560 db.commit() 

561 db.refresh(contact) 

562 

563 return ClientContactResponse.model_validate(contact) 

564 

565 

566@router.put( 

567 "/clients/{client_id}/contacts/{contact_id}", response_model=ClientContactResponse 

568) 

569async def update_client_contact( 

570 client_id: int, 

571 contact_id: int, 

572 data: ClientContactUpdate, 

573 user: BrokerUser, 

574 db: Session = Depends(get_db), 

575): 

576 """Update an existing client contact.""" 

577 client = db.get(Client, client_id) 

578 if not client or client.disabled_at: 

579 raise HTTPException(status_code=404, detail="Client not found") 

580 

581 # Authorization 

582 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

583 raise HTTPException(status_code=403, detail="Access denied") 

584 

585 contact = db.get(ClientContact, contact_id) 

586 if not contact or contact.client_id != client_id or contact.disabled_at: 

587 raise HTTPException(status_code=404, detail="Contact not found") 

588 

589 # Validation 

590 update_data = data.model_dump(exclude_unset=True) 

591 if "role" in update_data: 

592 _validate_contact_role(update_data["role"]) 

593 

594 # If setting as primary, unset primary for other contacts 

595 if update_data.get("is_primary"): 

596 existing_primary = db.scalars( 

597 select(ClientContact).where( 

598 ClientContact.client_id == client_id, 

599 ClientContact.is_primary == True, 

600 ClientContact.id != contact_id, 

601 ClientContact.disabled_at.is_(None), 

602 ) 

603 ).all() 

604 now = _now() 

605 for other_contact in existing_primary: 

606 other_contact.is_primary = False 

607 other_contact.updated_at = now 

608 

609 for field, value in update_data.items(): 

610 setattr(contact, field, value) 

611 

612 contact.updated_at = _now() 

613 db.commit() 

614 db.refresh(contact) 

615 

616 return ClientContactResponse.model_validate(contact) 

617 

618 

619@router.delete("/clients/{client_id}/contacts/{contact_id}") 

620async def delete_client_contact( 

621 client_id: int, 

622 contact_id: int, 

623 user: BrokerUser, 

624 db: Session = Depends(get_db), 

625): 

626 """Soft-delete a client contact.""" 

627 client = db.get(Client, client_id) 

628 if not client or client.disabled_at: 

629 raise HTTPException(status_code=404, detail="Client not found") 

630 

631 # Authorization 

632 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

633 raise HTTPException(status_code=403, detail="Access denied") 

634 

635 contact = db.get(ClientContact, contact_id) 

636 if not contact or contact.client_id != client_id: 

637 raise HTTPException(status_code=404, detail="Contact not found") 

638 

639 now = _now() 

640 contact.disabled_at = now 

641 contact.updated_at = now 

642 db.commit() 

643 

644 return {"message": "Contact deleted successfully"} 

645 

646 

647# ===== Client Activity CRUD Endpoints ===== 

648 

649 

650@router.get("/clients/{client_id}/activities") 

651async def list_client_activities( 

652 client_id: int, 

653 user: RequiredUser, 

654 db: Session = Depends(get_db), 

655 page: int = Query(1, ge=1), 

656 page_size: int = Query(20, ge=1, le=100), 

657): 

658 """List activities for a client with pagination.""" 

659 client = db.get(Client, client_id) 

660 if not client or client.disabled_at: 

661 raise HTTPException(status_code=404, detail="Client not found") 

662 

663 # Authorization 

664 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

665 raise HTTPException(status_code=403, detail="Access denied") 

666 

667 # Count total 

668 total = db.scalar( 

669 select(func.count()) 

670 .select_from(ClientActivity) 

671 .where(ClientActivity.client_id == client_id) 

672 ) 

673 

674 # Get paginated results 

675 offset = (page - 1) * page_size 

676 activities = db.scalars( 

677 select(ClientActivity) 

678 .where(ClientActivity.client_id == client_id) 

679 .order_by(ClientActivity.activity_at.desc()) 

680 .offset(offset) 

681 .limit(page_size) 

682 ).all() 

683 

684 total_pages = (total + page_size - 1) // page_size 

685 

686 return { 

687 "items": [ClientActivityResponse.model_validate(a) for a in activities], 

688 "total": total, 

689 "page": page, 

690 "page_size": page_size, 

691 "total_pages": total_pages, 

692 } 

693 

694 

695@router.post("/clients/{client_id}/activities", response_model=ClientActivityResponse) 

696async def create_client_activity( 

697 client_id: int, 

698 data: ClientActivityCreate, 

699 user: BrokerUser, 

700 db: Session = Depends(get_db), 

701): 

702 """Create a new activity for a client.""" 

703 client = db.get(Client, client_id) 

704 if not client or client.disabled_at: 

705 raise HTTPException(status_code=404, detail="Client not found") 

706 

707 # Authorization 

708 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

709 raise HTTPException(status_code=403, detail="Access denied") 

710 

711 # Validation 

712 _validate_activity_type(data.activity_type) 

713 

714 now = _now() 

715 activity = ClientActivity( 

716 client_id=client_id, 

717 **data.model_dump(), 

718 created_at=now, 

719 updated_at=now, 

720 ) 

721 

722 db.add(activity) 

723 

724 # Update client's last_contact_at 

725 client.last_contact_at = data.activity_at 

726 client.updated_at = now 

727 

728 db.commit() 

729 db.refresh(activity) 

730 

731 return ClientActivityResponse.model_validate(activity) 

732 

733 

734@router.put( 

735 "/clients/{client_id}/activities/{activity_id}", 

736 response_model=ClientActivityResponse, 

737) 

738async def update_client_activity( 

739 client_id: int, 

740 activity_id: int, 

741 data: ClientActivityUpdate, 

742 user: BrokerUser, 

743 db: Session = Depends(get_db), 

744): 

745 """Update an existing client activity.""" 

746 client = db.get(Client, client_id) 

747 if not client or client.disabled_at: 

748 raise HTTPException(status_code=404, detail="Client not found") 

749 

750 # Authorization 

751 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

752 raise HTTPException(status_code=403, detail="Access denied") 

753 

754 activity = db.get(ClientActivity, activity_id) 

755 if not activity or activity.client_id != client_id: 

756 raise HTTPException(status_code=404, detail="Activity not found") 

757 

758 # Validation 

759 update_data = data.model_dump(exclude_unset=True) 

760 if "activity_type" in update_data: 

761 _validate_activity_type(update_data["activity_type"]) 

762 

763 for field, value in update_data.items(): 

764 setattr(activity, field, value) 

765 

766 activity.updated_at = _now() 

767 db.commit() 

768 db.refresh(activity) 

769 

770 return ClientActivityResponse.model_validate(activity) 

771 

772 

773@router.delete("/clients/{client_id}/activities/{activity_id}") 

774async def delete_client_activity( 

775 client_id: int, 

776 activity_id: int, 

777 user: BrokerUser, 

778 db: Session = Depends(get_db), 

779): 

780 """Delete a client activity (hard delete).""" 

781 client = db.get(Client, client_id) 

782 if not client or client.disabled_at: 

783 raise HTTPException(status_code=404, detail="Client not found") 

784 

785 # Authorization 

786 if user.role != "admin" and user.brokerage_id != client.brokerage_id: 

787 raise HTTPException(status_code=403, detail="Access denied") 

788 

789 activity = db.get(ClientActivity, activity_id) 

790 if not activity or activity.client_id != client_id: 

791 raise HTTPException(status_code=404, detail="Activity not found") 

792 

793 db.delete(activity) 

794 db.commit() 

795 

796 return {"message": "Activity deleted successfully"}