GET /accounts/me/l1-categories (require_l1_or_above) returns enabled + available + hard_floor; PATCH (require_account_owner_or_admin) sets the enabled set, dropping unknown/hard-floored keys via l1_category_service. New L1CategoriesResponse/Update schemas. 6 API tests green (incl. engineer + l1_tech write both 403); test_accounts regression 36 passed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
897 lines
31 KiB
Python
897 lines
31 KiB
Python
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.admin_database import get_admin_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.account_settings import AccountSettings
|
|
from app.models.subscription import Subscription
|
|
from app.models.user import User
|
|
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, AccountInviteBulkCreate, AccountInviteBulkResponse, TransferOwnershipRequest
|
|
from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails
|
|
from app.schemas.user import UserResponse, AccountRoleUpdate, CoverageUpdate
|
|
from app.core.security import verify_password
|
|
from app.api.deps import (
|
|
get_current_active_user,
|
|
require_account_owner,
|
|
require_account_owner_or_admin,
|
|
require_engineer_or_admin,
|
|
require_l1_or_above,
|
|
)
|
|
from app.services import l1_category_service
|
|
from app.services.seat_enforcement import check_seat_available, get_seat_usage
|
|
from app.schemas.seat_enforcement import SeatUsage
|
|
from app.schemas.l1_categories import L1CategoriesResponse, L1CategoriesUpdate
|
|
|
|
_SEAT_CHECKED_ROLES = frozenset({"engineer", "l1_tech"})
|
|
|
|
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
|
|
|
|
|
async def _load_account(db: AsyncSession, account_id: UUID) -> Account:
|
|
"""Load an Account by id; raises 404 if missing."""
|
|
result = await db.execute(select(Account).where(Account.id == account_id))
|
|
account = result.scalar_one_or_none()
|
|
if account is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
|
return account
|
|
|
|
|
|
async def _enforce_seat_limit(db: AsyncSession, account_id: UUID, role: str) -> None:
|
|
"""Raise HTTP 402 if the account has no capacity for the given role.
|
|
|
|
Only fires for seat-counted roles (engineer, l1_tech).
|
|
Accounts without a subscription (free / pre-billing) are not blocked.
|
|
Grandfathering: if current > limit, existing users keep access; this
|
|
helper only blocks new additions.
|
|
"""
|
|
if role not in _SEAT_CHECKED_ROLES:
|
|
return
|
|
sub = await get_account_subscription(account_id, db)
|
|
if sub is None:
|
|
return # no subscription → no enforcement
|
|
account = await _load_account(db, account_id)
|
|
seat_result = await check_seat_available(account, sub, role, db)
|
|
if not seat_result.available:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail={
|
|
"code": "seat_limit_exceeded",
|
|
"role": seat_result.role,
|
|
"current": seat_result.current,
|
|
"limit": seat_result.limit,
|
|
"upgrade_url": "/account/billing",
|
|
},
|
|
)
|
|
|
|
|
|
@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.get("/me/seats", response_model=SeatUsage)
|
|
async def get_my_account_seat_usage(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
|
):
|
|
"""Returns engineer + l1_tech seat-usage counts. Accessible to engineer+.
|
|
|
|
Powers the SeatCounterWidget on admin/users and account/users surfaces.
|
|
"""
|
|
account = await _load_account(db, current_user.account_id)
|
|
sub = await get_account_subscription(current_user.account_id, db)
|
|
if sub is None:
|
|
# No subscription → treat as unlimited; return live counts with no limit
|
|
from sqlalchemy import func
|
|
engineer_count = (await db.execute(
|
|
select(func.count(User.id))
|
|
.where(User.account_id == account.id)
|
|
.where(User.account_role == "engineer")
|
|
.where(User.is_active.is_(True))
|
|
)).scalar_one()
|
|
l1_count = (await db.execute(
|
|
select(func.count(User.id))
|
|
.where(User.account_id == account.id)
|
|
.where(User.account_role == "l1_tech")
|
|
.where(User.is_active.is_(True))
|
|
)).scalar_one()
|
|
from app.schemas.seat_enforcement import SeatCheckResult
|
|
return SeatUsage(
|
|
engineer=SeatCheckResult(available=True, current=engineer_count, limit=None, role="engineer"),
|
|
l1_tech=SeatCheckResult(available=True, current=l1_count, limit=None, role="l1_tech"),
|
|
)
|
|
engineer, l1_tech = await get_seat_usage(account, sub, db)
|
|
return SeatUsage(engineer=engineer, l1_tech=l1_tech)
|
|
|
|
|
|
@router.get("/me/l1-categories", response_model=L1CategoriesResponse)
|
|
async def get_l1_categories(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_l1_or_above)],
|
|
):
|
|
"""The account's enabled L1 AI-build categories + the available + hard-floor lists.
|
|
|
|
Readable by any L1-or-above user (the walker needs to know what's buildable);
|
|
only owners/admins may change it (PATCH below).
|
|
"""
|
|
enabled = await l1_category_service.get_enabled_categories(current_user.account_id, db)
|
|
return L1CategoriesResponse(
|
|
enabled=enabled,
|
|
available=l1_category_service.DEFAULT_L1_CATEGORIES,
|
|
hard_floor=l1_category_service.HARD_FLOOR_FORBIDDEN,
|
|
)
|
|
|
|
|
|
@router.patch("/me/l1-categories", response_model=L1CategoriesResponse)
|
|
async def set_l1_categories(
|
|
payload: L1CategoriesUpdate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_account_owner_or_admin)],
|
|
):
|
|
"""Set the account's enabled L1 categories (owner/admin only).
|
|
|
|
Unknown and hard-floored keys are dropped by the service before persisting.
|
|
"""
|
|
enabled = await l1_category_service.set_enabled_categories(
|
|
current_user.account_id, payload.enabled, db
|
|
)
|
|
await db.commit()
|
|
return L1CategoriesResponse(
|
|
enabled=enabled,
|
|
available=l1_category_service.DEFAULT_L1_CATEGORIES,
|
|
hard_floor=l1_category_service.HARD_FLOOR_FORBIDDEN,
|
|
)
|
|
|
|
|
|
@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"
|
|
)
|
|
|
|
# Seat enforcement: check capacity before promoting to a seat-counted role.
|
|
# Demotions (engineer/l1_tech → viewer) and lateral moves skip the check.
|
|
if data.account_role != user.account_role:
|
|
await _enforce_seat_limit(db, current_user.account_id, data.account_role)
|
|
|
|
user.account_role = data.account_role
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
@router.patch("/me/members/{user_id}/coverage", response_model=UserResponse)
|
|
async def update_member_coverage(
|
|
user_id: UUID,
|
|
data: CoverageUpdate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_account_owner)],
|
|
):
|
|
"""Toggle the `can_cover_l1` flag on an engineer in your account.
|
|
|
|
Owner-only. Returns 404 if target user not in your account. Returns 422
|
|
if target user's role is not 'engineer' (coverage flag only applies to
|
|
engineers — owners/super_admins already see L1 surface; viewers/l1_techs
|
|
don't need this flag).
|
|
"""
|
|
result = await db.execute(
|
|
select(User).where(
|
|
User.id == user_id,
|
|
User.account_id == current_user.account_id,
|
|
)
|
|
)
|
|
target = result.scalar_one_or_none()
|
|
if target is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found in your account",
|
|
)
|
|
if target.account_role != "engineer":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="can_cover_l1 only applies to engineers",
|
|
)
|
|
target.can_cover_l1 = data.can_cover_l1
|
|
await db.commit()
|
|
await db.refresh(target)
|
|
return target
|
|
|
|
|
|
@router.post("/me/transfer-ownership", response_model=AccountResponse)
|
|
async def transfer_ownership(
|
|
data: TransferOwnershipRequest,
|
|
db: Annotated[AsyncSession, Depends(get_admin_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). Sends invite email."""
|
|
# Seat enforcement: block invite if the target role is at capacity.
|
|
await _enforce_seat_limit(db, current_user.account_id, data.role)
|
|
|
|
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.flush()
|
|
|
|
# Lookup account name for email
|
|
account_result = await db.execute(
|
|
select(Account).where(Account.id == current_user.account_id)
|
|
)
|
|
account = account_result.scalar_one()
|
|
|
|
# Send invite email — non-blocking on failure (function returns False on error)
|
|
email_sent = await EmailService.send_account_invite_email(
|
|
to_email=invite.email,
|
|
code=code,
|
|
account_name=account.name,
|
|
role=invite.role,
|
|
)
|
|
if email_sent:
|
|
invite.email_sent_at = datetime.now(timezone.utc)
|
|
|
|
await db.commit()
|
|
await db.refresh(invite)
|
|
return invite
|
|
|
|
|
|
@router.post("/me/invites/bulk", response_model=AccountInviteBulkResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_invites_bulk(
|
|
payload: AccountInviteBulkCreate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_account_owner)]
|
|
):
|
|
"""Create multiple invites in one call (wizard step 3 supports up to N).
|
|
Per-row failures are returned in `failed`; successes in `created`."""
|
|
# Lookup account once for email rendering
|
|
account_result = await db.execute(
|
|
select(Account).where(Account.id == current_user.account_id)
|
|
)
|
|
account = account_result.scalar_one()
|
|
|
|
created: list[AccountInvite] = []
|
|
failed: list[dict] = []
|
|
for invite_data in payload.invites:
|
|
try:
|
|
# Seat enforcement per invite row — 402 bubbles as an HTTPException
|
|
# which is caught below and recorded in `failed`.
|
|
await _enforce_seat_limit(db, current_user.account_id, invite_data.role)
|
|
|
|
code = secrets.token_urlsafe(16)
|
|
expires_at = None
|
|
if invite_data.expires_in_days:
|
|
expires_at = datetime.now(timezone.utc) + timedelta(days=invite_data.expires_in_days)
|
|
|
|
invite = AccountInvite(
|
|
account_id=current_user.account_id,
|
|
invited_by_id=current_user.id,
|
|
email=invite_data.email,
|
|
code=code,
|
|
role=invite_data.role,
|
|
expires_at=expires_at,
|
|
)
|
|
db.add(invite)
|
|
await db.flush()
|
|
|
|
email_sent = await EmailService.send_account_invite_email(
|
|
to_email=invite.email,
|
|
code=code,
|
|
account_name=account.name,
|
|
role=invite.role,
|
|
)
|
|
if email_sent:
|
|
invite.email_sent_at = datetime.now(timezone.utc)
|
|
|
|
created.append(invite)
|
|
except HTTPException as exc:
|
|
failed.append({"email": invite_data.email, "error": exc.detail})
|
|
except Exception as e:
|
|
failed.append({"email": invite_data.email, "error": str(e)})
|
|
|
|
await db.commit()
|
|
for inv in created:
|
|
await db.refresh(inv)
|
|
|
|
return AccountInviteBulkResponse(created=created, failed=failed)
|
|
|
|
|
|
@router.delete("/me/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def revoke_invite(
|
|
invite_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_account_owner)]
|
|
):
|
|
"""Soft-revoke an invitation by setting revoked_at. Idempotent on already-
|
|
revoked invites; rejects already-accepted invites."""
|
|
result = await db.execute(
|
|
select(AccountInvite).where(
|
|
AccountInvite.id == invite_id,
|
|
AccountInvite.account_id == current_user.account_id,
|
|
)
|
|
)
|
|
invite = result.scalar_one_or_none()
|
|
if not invite:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
|
if invite.is_revoked:
|
|
return None # idempotent
|
|
if invite.is_used:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot revoke an accepted invite")
|
|
invite.revoked_at = datetime.now(timezone.utc)
|
|
await db.commit()
|
|
return None
|
|
|
|
|
|
@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_admin_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_admin_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"}
|
|
|
|
|
|
# ─── Account Branding Endpoints (Task 9) ──────────────────────────────────────
|
|
|
|
class AccountBrandingResponse(BaseModel):
|
|
logo_url: Optional[str] = None
|
|
primary_color: Optional[str] = None
|
|
company_name: Optional[str] = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class AccountBrandingUpdate(BaseModel):
|
|
logo_url: Optional[str] = None
|
|
primary_color: Optional[str] = None
|
|
company_name: Optional[str] = None
|
|
|
|
|
|
@router.get("/me/branding", response_model=AccountBrandingResponse)
|
|
async def get_account_branding(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
):
|
|
"""Get custom branding settings for the current 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=404, detail="Account not found")
|
|
|
|
return AccountBrandingResponse(
|
|
logo_url=account.branding_logo_url,
|
|
primary_color=account.branding_primary_color,
|
|
company_name=account.branding_company_name,
|
|
)
|
|
|
|
|
|
@router.patch("/me/branding", response_model=AccountBrandingResponse)
|
|
async def update_account_branding(
|
|
data: AccountBrandingUpdate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_account_owner)],
|
|
):
|
|
"""Update custom branding settings. Account 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=404, detail="Account not found")
|
|
|
|
if data.logo_url is not None:
|
|
account.branding_logo_url = data.logo_url or None
|
|
if data.primary_color is not None:
|
|
# Validate hex color format (#RRGGBB)
|
|
color = data.primary_color.strip()
|
|
if color and (len(color) != 7 or not color.startswith("#")):
|
|
raise HTTPException(status_code=400, detail="primary_color must be a 7-character hex string like #06b6d4")
|
|
account.branding_primary_color = color or None
|
|
if data.company_name is not None:
|
|
account.branding_company_name = data.company_name.strip() or None
|
|
|
|
await db.commit()
|
|
await db.refresh(account)
|
|
|
|
return AccountBrandingResponse(
|
|
logo_url=account.branding_logo_url,
|
|
primary_color=account.branding_primary_color,
|
|
company_name=account.branding_company_name,
|
|
)
|
|
|
|
|
|
# ─── SSO Status Endpoint (Task 11) ────────────────────────────────────────────
|
|
|
|
class AccountSSOStatusResponse(BaseModel):
|
|
sso_enabled: bool
|
|
sso_provider: Optional[str] = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@router.get("/me/sso", response_model=AccountSSOStatusResponse)
|
|
async def get_sso_status(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
):
|
|
"""Get SSO configuration status for the current 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=404, detail="Account not found")
|
|
|
|
return AccountSSOStatusResponse(
|
|
sso_enabled=account.sso_enabled,
|
|
sso_provider=account.sso_provider,
|
|
)
|
|
|
|
|
|
# ─── Account Preferences (FlowPilot Phase 6) ──────────────────────────────────
|
|
#
|
|
# Preferences live in `account_settings.preferences` as a JSONB grab-bag
|
|
# (per FLOWPILOT-MIGRATION.md Section 4.6). Rows are lazily created on first
|
|
# write. Any engineer-role user can read + update preferences because the
|
|
# keys stored here (templatize_prompt_enabled, cw_resolved_status_id, etc.)
|
|
# are team-level toggles rather than account-owner-gated admin settings.
|
|
|
|
|
|
class AccountPreferencesResponse(BaseModel):
|
|
preferences: dict
|
|
|
|
|
|
class AccountPreferencesUpdate(BaseModel):
|
|
"""Merge-style update — each key in `preferences` overwrites that key in
|
|
the stored JSONB, other keys are preserved. Omit the body entirely to
|
|
no-op.
|
|
"""
|
|
preferences: dict
|
|
|
|
|
|
@router.get("/me/preferences", response_model=AccountPreferencesResponse)
|
|
async def get_my_preferences(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
):
|
|
"""Return the current account's preferences JSONB (empty dict if no row)."""
|
|
result = await db.execute(
|
|
select(AccountSettings.preferences).where(
|
|
AccountSettings.account_id == current_user.account_id
|
|
)
|
|
)
|
|
prefs = result.scalar_one_or_none() or {}
|
|
return AccountPreferencesResponse(preferences=prefs)
|
|
|
|
|
|
@router.patch("/me/preferences", response_model=AccountPreferencesResponse)
|
|
async def update_my_preferences(
|
|
data: AccountPreferencesUpdate,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
):
|
|
"""Upsert preference keys. Existing keys not present in the payload are kept.
|
|
|
|
Example: posting `{"preferences": {"templatize_prompt_enabled": false}}`
|
|
from the post-resolve "Don't ask me again for this team" checkbox sets
|
|
just that key without clobbering any other preferences.
|
|
"""
|
|
for key, value in data.preferences.items():
|
|
await AccountSettings.set_setting(db, current_user.account_id, key, value)
|
|
await db.commit()
|
|
|
|
# Return the merged state so the client doesn't need a second GET.
|
|
result = await db.execute(
|
|
select(AccountSettings.preferences).where(
|
|
AccountSettings.account_id == current_user.account_id
|
|
)
|
|
)
|
|
prefs = result.scalar_one_or_none() or {}
|
|
return AccountPreferencesResponse(preferences=prefs)
|