# app/modules/tenancy/routes/api/merchant.py """ Tenancy module merchant API routes. Aggregates all merchant tenancy routes: - /auth/* - Merchant authentication (login, logout, /me) - /account/* - Merchant account management (stores, profile) Auto-discovered by the route system (merchant.py in routes/api/). """ import logging from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.orm import Session from app.api.deps import get_current_merchant_api, get_merchant_for_current_user from app.core.database import get_db from app.modules.tenancy.schemas import ( MerchantPortalProfileResponse, MerchantPortalProfileUpdate, MerchantPortalStoreListResponse, MerchantStoreCreate, MerchantStoreDetailResponse, MerchantStoreUpdate, ) from app.modules.tenancy.schemas.auth import UserContext from app.modules.tenancy.schemas.team import ( MerchantTeamInvite, MerchantTeamProfileUpdate, ) from app.modules.tenancy.services.merchant_service import merchant_service from app.modules.tenancy.services.merchant_store_service import merchant_store_service from .email_verification import email_verification_api_router from .merchant_auth import merchant_auth_router from .user_account import merchant_account_router logger = logging.getLogger(__name__) router = APIRouter() # Include auth routes (/auth/login, /auth/logout, /auth/me, /auth/forgot-password, /auth/reset-password) router.include_router(merchant_auth_router, tags=["merchant-auth"]) # Include email verification routes (/resend-verification) router.include_router(email_verification_api_router, tags=["email-verification"]) # Account routes are defined below with /account prefix _account_router = APIRouter(prefix="/account") # ============================================================================ # ACCOUNT ENDPOINTS # ============================================================================ @_account_router.get("/stores", response_model=MerchantPortalStoreListResponse) async def merchant_stores( request: Request, skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(100, ge=1, le=200, description="Max records to return"), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """ List all stores belonging to the merchant. Returns a paginated list of store summaries for the authenticated merchant. """ stores, total = merchant_service.get_merchant_stores( db, merchant.id, skip=skip, limit=limit ) can_create, _ = merchant_store_service.can_create_store(db, merchant.id) return MerchantPortalStoreListResponse( stores=stores, total=total, skip=skip, limit=limit, can_create_store=can_create, ) @_account_router.post("/stores", response_model=MerchantStoreDetailResponse) async def create_merchant_store( store_data: MerchantStoreCreate, current_user: UserContext = Depends(get_current_merchant_api), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """ Create a new store under the merchant. Checks subscription tier store limits before creation. New stores are created with is_active=True, is_verified=False. """ # Service raises MaxStoresReachedException, StoreAlreadyExistsException, # or StoreValidationException — all handled by global exception handler. result = merchant_store_service.create_store( db, merchant.id, store_data.model_dump(), ) db.commit() logger.info( f"Merchant {merchant.id} ({current_user.username}) created store " f"'{store_data.store_code}'" ) return result @_account_router.get("/stores/{store_id}", response_model=MerchantStoreDetailResponse) async def get_merchant_store_detail( store_id: int, merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """ Get detailed store information with ownership validation. Returns store details including platform assignments. """ # StoreNotFoundException handled by global exception handler return merchant_store_service.get_store_detail(db, merchant.id, store_id) @_account_router.put("/stores/{store_id}", response_model=MerchantStoreDetailResponse) async def update_merchant_store( store_id: int, update_data: MerchantStoreUpdate, current_user: UserContext = Depends(get_current_merchant_api), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """ Update store details (merchant-allowed fields only). Only name, description, and contact info fields can be updated. """ # StoreNotFoundException handled by global exception handler result = merchant_store_service.update_store( db, merchant.id, store_id, update_data.model_dump(exclude_unset=True), ) db.commit() logger.info( f"Merchant {merchant.id} ({current_user.username}) updated store {store_id}" ) return result @_account_router.get("/platforms") async def get_merchant_platforms( merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """ Get platforms available for the merchant (from active subscriptions). Used by the store creation/edit UI to show platform selection options. """ return merchant_store_service.get_subscribed_platform_ids(db, merchant.id) @_account_router.get("/team") async def merchant_team_overview( merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """ Get team members across all merchant stores (member-centric view). Returns deduplicated members with per-store role info. """ return merchant_store_service.get_merchant_team_members(db, merchant.id) @_account_router.get("/team/stores/{store_id}/roles") async def merchant_team_store_roles( store_id: int, current_user: UserContext = Depends(get_current_merchant_api), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """Get available roles for a specific store.""" from app.modules.tenancy.services.store_team_service import store_team_service merchant_store_service.validate_store_ownership(db, merchant.id, store_id) roles = store_team_service.get_store_roles(db, store_id) return {"roles": roles, "total": len(roles)} @_account_router.post("/team/invite") async def merchant_team_invite( data: MerchantTeamInvite, current_user: UserContext = Depends(get_current_merchant_api), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """Invite a member to one or more merchant stores.""" from app.modules.tenancy.schemas.team import ( MerchantTeamInviteResponse, MerchantTeamInviteResult, ) from app.modules.tenancy.services.store_team_service import store_team_service # Get the User ORM object (service needs it as inviter) inviter = merchant_store_service.get_user(db, current_user.id) results = [] for store_id in data.store_ids: try: store = merchant_store_service.validate_store_ownership( db, merchant.id, store_id ) store_team_service.invite_team_member( db, store=store, inviter=inviter, email=data.email, role_name=data.role_name, first_name=data.first_name, last_name=data.last_name, ) results.append(MerchantTeamInviteResult( store_id=store.id, store_name=store.name, success=True, )) except Exception as e: results.append(MerchantTeamInviteResult( store_id=store_id, store_name=getattr(e, "store_name", str(store_id)), success=False, error=str(e), )) db.commit() success_count = sum(1 for r in results if r.success) if success_count == len(results): message = f"Invitation sent to {data.email} for {success_count} store(s)" elif success_count > 0: message = f"Invitation partially sent ({success_count}/{len(results)} stores)" else: message = "Invitation failed for all stores" return MerchantTeamInviteResponse( message=message, email=data.email, results=results, ) @_account_router.put("/team/members/{user_id}/profile") async def merchant_team_update_profile( user_id: int, data: MerchantTeamProfileUpdate, current_user: UserContext = Depends(get_current_merchant_api), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """Update a team member's profile (first name, last name, email).""" merchant_store_service.update_team_member_profile( db, merchant.id, user_id, data.model_dump(exclude_unset=True) ) db.commit() logger.info( f"Merchant {merchant.id} updated profile for user {user_id} " f"by {current_user.username}" ) return {"message": "Profile updated successfully"} @_account_router.put("/team/stores/{store_id}/members/{user_id}") async def merchant_team_update_role( store_id: int, user_id: int, role_name: str = Query(..., description="New role name"), current_user: UserContext = Depends(get_current_merchant_api), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """Update a member's role in a specific store.""" from app.modules.tenancy.services.store_team_service import store_team_service store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id) store_team_service.update_member_role( db, store=store, user_id=user_id, new_role_name=role_name, actor_user_id=current_user.id, ) db.commit() return {"message": "Role updated successfully"} @_account_router.post("/team/stores/{store_id}/members/{user_id}/resend") async def merchant_team_resend_invitation( store_id: int, user_id: int, current_user: UserContext = Depends(get_current_merchant_api), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """Resend invitation to a pending team member.""" from app.modules.tenancy.services.store_team_service import store_team_service store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id) inviter = merchant_store_service.get_user(db, current_user.id) result = store_team_service.resend_invitation( db, store=store, user_id=user_id, inviter=inviter, ) db.commit() return result @_account_router.delete("/team/stores/{store_id}/members/{user_id}", status_code=204) async def merchant_team_remove_member( store_id: int, user_id: int, current_user: UserContext = Depends(get_current_merchant_api), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """Remove a member from a specific store.""" from app.modules.tenancy.services.store_team_service import store_team_service store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id) store_team_service.remove_team_member( db, store=store, user_id=user_id, actor_user_id=current_user.id, ) db.commit() @_account_router.get("/profile", response_model=MerchantPortalProfileResponse) async def merchant_profile( request: Request, merchant=Depends(get_merchant_for_current_user), ): """ Get the authenticated merchant's profile information. Returns merchant details including contact info, business details, and verification status. """ return merchant @_account_router.put("/profile", response_model=MerchantPortalProfileResponse) async def update_merchant_profile( request: Request, profile_data: MerchantPortalProfileUpdate, current_user: UserContext = Depends(get_current_merchant_api), merchant=Depends(get_merchant_for_current_user), db: Session = Depends(get_db), ): """ Update the authenticated merchant's profile information. Accepts partial updates - only provided fields are changed. """ # Apply only the fields that were explicitly provided update_data = profile_data.model_dump(exclude_unset=True) for field_name, value in update_data.items(): setattr(merchant, field_name, value) db.commit() db.refresh(merchant) logger.info( f"Merchant profile updated: merchant_id={merchant.id}, " f"user={current_user.username}, fields={list(update_data.keys())}" ) return merchant # Include account routes in main router router.include_router(_account_router, tags=["merchant-account"]) # Include self-service user account routes router.include_router(merchant_account_router, tags=["merchant-user-account"])