Files
resolutionflow/backend/app/api/endpoints/accounts.py
Michael Chihlas 1d3f9d0a8a feat(l1): account L1 category settings API (owner/admin write)
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>
2026-05-30 20:01:32 -04:00

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)