from datetime import datetime, timezone, timedelta from typing import Annotated, Optional from uuid import UUID import secrets import string from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from app.core.database import get_db from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage from app.core.audit import log_audit from app.models.refresh_token import RefreshToken from app.core.email import EmailService from app.models.account import Account from app.models.account_invite import AccountInvite from app.models.subscription import Subscription from app.models.user import User from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails from app.schemas.user import UserResponse, AccountRoleUpdate from app.core.security import verify_password from app.api.deps import get_current_active_user, require_account_owner router = APIRouter(prefix="/accounts", tags=["accounts"]) @router.get("/me", response_model=AccountResponse) async def get_my_account( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)] ): """Get current user's account.""" result = await db.execute( select(Account).where(Account.id == current_user.account_id) ) account = result.scalar_one_or_none() if not account: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Account not found" ) return account @router.get("/me/subscription", response_model=SubscriptionDetails) async def get_my_subscription( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)] ): """Get current user's subscription details including limits and usage.""" sub = await get_account_subscription(current_user.account_id, db) if not sub: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No subscription found" ) limits = await get_plan_limits(sub.plan, db) if not limits: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Plan limits not configured" ) usage = await get_account_usage(current_user.account_id, db) return SubscriptionDetails( subscription=SubscriptionResponse.model_validate(sub), limits=PlanLimitsResponse.model_validate(limits), usage=UsageResponse(**usage), ) @router.get("/me/members", response_model=list[UserResponse]) async def get_my_members( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)] ): """Get members of current user's account.""" result = await db.execute( select(User).where(User.account_id == current_user.account_id) .order_by(User.created_at) ) return result.scalars().all() @router.patch("/me", response_model=AccountResponse) async def update_my_account( data: AccountUpdate, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """Update account settings (owner only).""" result = await db.execute( select(Account).where(Account.id == current_user.account_id) ) account = result.scalar_one_or_none() if not account: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Account not found" ) update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(account, field, value) await db.commit() await db.refresh(account) return account @router.patch("/me/members/{user_id}/role", response_model=UserResponse) async def update_member_role( user_id: UUID, data: AccountRoleUpdate, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """Change a member's role within the account (owner only).""" result = await db.execute( select(User).where( User.id == user_id, User.account_id == current_user.account_id ) ) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found in your account" ) if user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change your own role" ) user.account_role = data.account_role await db.commit() await db.refresh(user) return user @router.post("/me/transfer-ownership", response_model=AccountResponse) async def transfer_ownership( data: TransferOwnershipRequest, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """Transfer account ownership to another member (owner only).""" if not verify_password(data.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect" ) if data.target_user_id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot transfer ownership to yourself" ) result = await db.execute( select(User).where( User.id == data.target_user_id, User.account_id == current_user.account_id ) ) target_user = result.scalar_one_or_none() if not target_user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found in your account" ) # Swap roles current_user.account_role = "engineer" target_user.account_role = "owner" # Update account owner result = await db.execute( select(Account).where(Account.id == current_user.account_id) ) account = result.scalar_one() account.owner_id = target_user.id await log_audit( db, current_user.id, "account.ownership_transfer", "account", account.id, {"new_owner_id": str(target_user.id)} ) await db.commit() await db.refresh(account) return account @router.delete("/me/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_member( user_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """Remove a member from the account (owner only). The removed user gets a new personal account. """ result = await db.execute( select(User).where( User.id == user_id, User.account_id == current_user.account_id ) ) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found in your account" ) if user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot remove yourself from your own account" ) # Create a personal account for the removed user chars = string.ascii_uppercase + string.digits display_code = ''.join(secrets.choice(chars) for _ in range(8)) new_account = Account( name=f"{user.name}'s Account", display_code=display_code, owner_id=user.id, ) db.add(new_account) await db.flush() new_sub = Subscription( account_id=new_account.id, plan="free", status="active", ) db.add(new_sub) user.account_id = new_account.id user.account_role = "owner" await db.commit() return None @router.post("/me/invites", response_model=AccountInviteResponse, status_code=status.HTTP_201_CREATED) async def create_invite( data: AccountInviteCreate, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """Create an invite to join this account (owner only).""" code = secrets.token_urlsafe(16) expires_at = None if data.expires_in_days: expires_at = datetime.now(timezone.utc) + timedelta(days=data.expires_in_days) invite = AccountInvite( account_id=current_user.account_id, invited_by_id=current_user.id, email=data.email, code=code, role=data.role, expires_at=expires_at, ) db.add(invite) await db.commit() await db.refresh(invite) return invite @router.post("/me/invites/{invite_id}/resend", response_model=AccountInviteResponse) async def resend_invite( invite_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """Revoke an existing account invite and create a new one, then email it.""" result = await db.execute( select(AccountInvite).where( AccountInvite.id == invite_id, AccountInvite.account_id == current_user.account_id, ) ) old_invite = result.scalar_one_or_none() if not old_invite: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found" ) if old_invite.is_used: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Cannot resend a used invite" ) # Recalculate expiration from now if the old one had an expiration new_expires_at = None if old_invite.expires_at and old_invite.created_at: original_duration = old_invite.expires_at - old_invite.created_at new_expires_at = datetime.now(timezone.utc) + original_duration elif old_invite.expires_at: new_expires_at = old_invite.expires_at # Capture properties before deleting email = old_invite.email role = old_invite.role await db.delete(old_invite) await db.flush() # Create new invite code = secrets.token_urlsafe(16) new_invite = AccountInvite( account_id=current_user.account_id, invited_by_id=current_user.id, email=email, code=code, role=role, expires_at=new_expires_at, ) db.add(new_invite) await db.flush() # Get account name for email account_result = await db.execute( select(Account).where(Account.id == current_user.account_id) ) account = account_result.scalar_one() email_sent = await EmailService.send_account_invite_email( to_email=email, code=code, account_name=account.name, role=role, ) await log_audit( db, current_user.id, "account_invite.resend", "account_invite", new_invite.id, { "email": email, "role": role, "email_sent": email_sent, }, ) await db.commit() await db.refresh(new_invite) return new_invite @router.get("/me/invites", response_model=list[AccountInviteResponse]) async def list_invites( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """List invites for this account (owner only).""" result = await db.execute( select(AccountInvite) .where(AccountInvite.account_id == current_user.account_id) .order_by(AccountInvite.created_at.desc()) ) return result.scalars().all() @router.post("/me/leave") async def leave_account( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)] ): """Leave the current account (non-owners only). Creates a personal account.""" if current_user.account_role == "owner": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Account owners cannot leave. Transfer ownership first." ) # Create a personal account (same pattern as remove_member) chars = string.ascii_uppercase + string.digits display_code = ''.join(secrets.choice(chars) for _ in range(8)) new_account = Account( name=f"{current_user.name}'s Account", display_code=display_code, owner_id=current_user.id, ) db.add(new_account) await db.flush() new_sub = Subscription( account_id=new_account.id, plan="free", status="active", ) db.add(new_sub) old_account_id = current_user.account_id current_user.account_id = new_account.id current_user.account_role = "owner" await log_audit(db, current_user.id, "account.leave", "account", old_account_id) await db.commit() return {"message": "You have left the account"} class DeleteAccountRequest(BaseModel): current_password: str @router.delete("/me") async def delete_account( data: DeleteAccountRequest, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """Delete the current account and soft-delete the user (owner only, no other members).""" if not verify_password(data.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect" ) # Check no other members result = await db.execute( select(User).where( User.account_id == current_user.account_id, User.id != current_user.id, User.deleted_at.is_(None) ) ) if result.scalars().first(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete account with other members. Remove them first." ) # Soft-delete user current_user.deleted_at = datetime.now(timezone.utc) current_user.is_active = False # Revoke all refresh tokens rt_result = await db.execute( select(RefreshToken).where( RefreshToken.user_id == current_user.id, RefreshToken.revoked_at.is_(None) ) ) for rt in rt_result.scalars().all(): rt.revoked_at = datetime.now(timezone.utc) await log_audit(db, current_user.id, "account.delete", "account", current_user.account_id) await db.commit() return {"message": "Account deleted"}