Merge feat/l1-workspace into integration branch
# Conflicts: # frontend/src/router.tsx
This commit is contained in:
@@ -199,6 +199,53 @@ async def require_engineer_or_admin(
|
||||
)
|
||||
|
||||
|
||||
async def require_l1(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""L1 tech exact-match (with super_admin bypass for support)."""
|
||||
if current_user.is_super_admin:
|
||||
return current_user
|
||||
if current_user.account_role != "l1_tech":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="L1 tech role required",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_l1_or_coverage(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""L1 endpoints: l1_tech, owners, super_admin, or engineers with can_cover_l1=True."""
|
||||
if current_user.is_super_admin:
|
||||
return current_user
|
||||
role = current_user.account_role
|
||||
if role == "l1_tech":
|
||||
return current_user
|
||||
if role == "owner":
|
||||
return current_user
|
||||
if role == "engineer" and current_user.can_cover_l1:
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="L1 access requires l1_tech role or engineer coverage flag",
|
||||
)
|
||||
|
||||
|
||||
async def require_l1_or_above(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""Any tier from l1_tech upward (l1_tech, engineer, owner, super_admin)."""
|
||||
if current_user.is_super_admin:
|
||||
return current_user
|
||||
if current_user.account_role in ("l1_tech", "engineer", "owner"):
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="L1 or above required",
|
||||
)
|
||||
|
||||
|
||||
async def require_team_admin(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
|
||||
@@ -21,13 +21,54 @@ 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
|
||||
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
|
||||
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
||||
from app.services.seat_enforcement import check_seat_available, get_seat_usage
|
||||
from app.schemas.seat_enforcement import SeatUsage
|
||||
|
||||
_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)],
|
||||
@@ -88,6 +129,41 @@ async def get_my_members(
|
||||
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.patch("/me", response_model=AccountResponse)
|
||||
async def update_my_account(
|
||||
data: AccountUpdate,
|
||||
@@ -141,12 +217,54 @@ async def update_member_role(
|
||||
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,
|
||||
@@ -261,6 +379,9 @@ async def create_invite(
|
||||
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
|
||||
@@ -317,6 +438,10 @@ async def create_invites_bulk(
|
||||
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:
|
||||
@@ -343,6 +468,8 @@ async def create_invites_bulk(
|
||||
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)})
|
||||
|
||||
|
||||
@@ -289,6 +289,33 @@ async def register(
|
||||
detail="Invite code has expired"
|
||||
)
|
||||
|
||||
# Seat enforcement: re-check at accept time (race-condition guard).
|
||||
# Fires only when an account invite is being accepted and the target role
|
||||
# is seat-counted (engineer, l1_tech). Accounts without a subscription
|
||||
# (free / pre-billing) are not blocked.
|
||||
if account_invite_record and account_invite_record.role in ("engineer", "l1_tech"):
|
||||
from app.core.subscriptions import get_account_subscription
|
||||
from app.services.seat_enforcement import check_seat_available
|
||||
from app.models.account import Account as _Account
|
||||
sub = await get_account_subscription(account_invite_record.account_id, db)
|
||||
if sub is not None:
|
||||
acct_result = await db.execute(
|
||||
select(_Account).where(_Account.id == account_invite_record.account_id)
|
||||
)
|
||||
acct = acct_result.scalar_one()
|
||||
seat_result = await check_seat_available(acct, sub, account_invite_record.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",
|
||||
},
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
result = await db.execute(select(User).where(User.email == user_data.email))
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
277
backend/app/api/endpoints/l1.py
Normal file
277
backend/app/api/endpoints/l1.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""L1 Workspace endpoints (Phase 1).
|
||||
|
||||
PSA-merge queue support + AI build path are deferred to Phase 2.
|
||||
"""
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status as http_status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, require_l1_or_coverage
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
from app.models.user import User
|
||||
from app.schemas.l1 import (
|
||||
EscalateRequest,
|
||||
EscalateWithoutWalkRequest,
|
||||
IntakeRequest,
|
||||
IntakeResponse,
|
||||
NotesRequest,
|
||||
QueueRow,
|
||||
ResolveRequest,
|
||||
StepRequest,
|
||||
WalkSessionResponse,
|
||||
)
|
||||
from app.services import internal_ticket_service, l1_session_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/l1", tags=["l1"])
|
||||
|
||||
|
||||
def _to_response(session: L1WalkSession) -> WalkSessionResponse:
|
||||
return WalkSessionResponse(
|
||||
id=session.id,
|
||||
session_kind=session.session_kind,
|
||||
flow_id=session.flow_id,
|
||||
flow_proposal_id=session.flow_proposal_id,
|
||||
current_node_id=session.current_node_id,
|
||||
walked_path=session.walked_path or [],
|
||||
walk_notes=session.walk_notes or [],
|
||||
status=session.status,
|
||||
started_at=session.started_at,
|
||||
last_step_at=session.last_step_at,
|
||||
resolved_at=session.resolved_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_session_or_404(
|
||||
db: AsyncSession, session_id: UUID, user: User
|
||||
) -> L1WalkSession:
|
||||
"""Fetch a session by id, scoped to the caller's account.
|
||||
|
||||
Phase 1 policy (per spec §7.9): sessions are account-scoped, not
|
||||
user-scoped. Any L1 or coverage engineer in the same account can
|
||||
step/note/resolve/escalate any session — supports team coverage
|
||||
(e.g., L1 hands off mid-shift; coverage engineer takes over a call).
|
||||
For a stricter "creator-only" policy, add
|
||||
``created_by_user_id == user.id`` here.
|
||||
"""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if session is None or session.account_id != user.account_id:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found",
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
@router.post("/intake", response_model=IntakeResponse)
|
||||
async def intake(
|
||||
payload: IntakeRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
"""L1 intake: creates an internal ticket and starts a walk session.
|
||||
|
||||
Phase 1: internal-ticket only (PSA support follows in Phase 2 escalation polish).
|
||||
If `flow_id` is provided, starts a flow session; otherwise an adhoc session.
|
||||
"""
|
||||
ticket = await internal_ticket_service.create_ticket(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
created_by_user_id=user.id,
|
||||
problem_statement=payload.problem_statement,
|
||||
customer_name=payload.customer_name,
|
||||
customer_contact=payload.customer_contact,
|
||||
)
|
||||
if payload.flow_id is not None:
|
||||
session = await l1_session_service.start_flow_session(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
user=user,
|
||||
flow_id=payload.flow_id,
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
)
|
||||
else:
|
||||
session = await l1_session_service.start_adhoc_session(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
user=user,
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
)
|
||||
await db.commit()
|
||||
return IntakeResponse(
|
||||
session_id=session.id,
|
||||
session_kind=session.session_kind,
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/queue", response_model=list[QueueRow])
|
||||
async def queue(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
status_filter: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
):
|
||||
"""Phase 1 queue: internal tickets only. PSA-fed rows in Phase 2."""
|
||||
tickets = await internal_ticket_service.list_tickets_for_account(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
status=status_filter,
|
||||
limit=limit,
|
||||
)
|
||||
return [
|
||||
QueueRow(
|
||||
ticket_id=str(t.id),
|
||||
ticket_kind="internal",
|
||||
problem_statement=t.problem_statement,
|
||||
customer_name=t.customer_name,
|
||||
status=t.status,
|
||||
created_at=t.created_at,
|
||||
)
|
||||
for t in tickets
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sessions/active", response_model=list[WalkSessionResponse])
|
||||
async def list_active_sessions(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
"""The caller's currently-active sessions (for the dashboard 'Resume in progress' widget)."""
|
||||
stmt = (
|
||||
select(L1WalkSession)
|
||||
.where(L1WalkSession.created_by_user_id == user.id)
|
||||
.where(L1WalkSession.status == "active")
|
||||
.order_by(L1WalkSession.last_step_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return [_to_response(s) for s in result.scalars()]
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=WalkSessionResponse)
|
||||
async def get_session(
|
||||
session_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
session = await _get_session_or_404(db, session_id, user)
|
||||
return _to_response(session)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/step", response_model=WalkSessionResponse)
|
||||
async def post_step(
|
||||
session_id: UUID,
|
||||
payload: StepRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
await _get_session_or_404(db, session_id, user)
|
||||
try:
|
||||
updated = await l1_session_service.record_step(
|
||||
db,
|
||||
session_id=session_id,
|
||||
node_id=payload.node_id,
|
||||
question=payload.question,
|
||||
answer=payload.answer,
|
||||
note=payload.note,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
await db.commit()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/notes", response_model=WalkSessionResponse)
|
||||
async def post_notes(
|
||||
session_id: UUID,
|
||||
payload: NotesRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
await _get_session_or_404(db, session_id, user)
|
||||
try:
|
||||
updated = await l1_session_service.update_notes(
|
||||
db,
|
||||
session_id=session_id,
|
||||
notes=payload.notes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
await db.commit()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/resolve", response_model=WalkSessionResponse)
|
||||
async def post_resolve(
|
||||
session_id: UUID,
|
||||
payload: ResolveRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
await _get_session_or_404(db, session_id, user)
|
||||
try:
|
||||
updated = await l1_session_service.resolve(
|
||||
db,
|
||||
session_id=session_id,
|
||||
helpful=payload.helpful,
|
||||
resolution_notes=payload.resolution_notes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
await db.commit()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/escalate", response_model=WalkSessionResponse)
|
||||
async def post_escalate(
|
||||
session_id: UUID,
|
||||
payload: EscalateRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
await _get_session_or_404(db, session_id, user)
|
||||
try:
|
||||
updated = await l1_session_service.escalate(
|
||||
db,
|
||||
session_id=session_id,
|
||||
reason=payload.reason or "",
|
||||
reason_category=payload.reason_category,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
await db.commit()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
@router.post("/escalate-without-walk", response_model=WalkSessionResponse)
|
||||
async def post_escalate_without_walk(
|
||||
payload: EscalateWithoutWalkRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
ticket = await internal_ticket_service.create_ticket(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
created_by_user_id=user.id,
|
||||
problem_statement=payload.problem_statement,
|
||||
customer_name=payload.customer_name,
|
||||
customer_contact=payload.customer_contact,
|
||||
)
|
||||
session = await l1_session_service.escalate_without_walk(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
user=user,
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
reason_category=payload.reason_category,
|
||||
reason=payload.reason,
|
||||
)
|
||||
await db.commit()
|
||||
return _to_response(session)
|
||||
@@ -3,7 +3,7 @@ import string
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -118,6 +118,29 @@ async def _sign_in_or_register(
|
||||
|
||||
if is_new_user:
|
||||
if invite_record is not None:
|
||||
# Seat enforcement: re-check at OAuth accept time (race-condition guard).
|
||||
if invite_record.role in ("engineer", "l1_tech"):
|
||||
from app.core.subscriptions import get_account_subscription
|
||||
from app.services.seat_enforcement import check_seat_available
|
||||
sub = await get_account_subscription(invite_record.account_id, db)
|
||||
if sub is not None:
|
||||
acct_result = await db.execute(
|
||||
select(Account).where(Account.id == invite_record.account_id)
|
||||
)
|
||||
acct = acct_result.scalar_one()
|
||||
seat_result = await check_seat_available(acct, sub, invite_record.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",
|
||||
},
|
||||
)
|
||||
|
||||
# Join the invited account directly — no personal account, no
|
||||
# trial creation.
|
||||
user = User(
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.api.deps import (
|
||||
from app.api.endpoints import (
|
||||
admin,
|
||||
admin_audit,
|
||||
l1,
|
||||
admin_categories,
|
||||
admin_dashboard,
|
||||
admin_feature_flags,
|
||||
@@ -185,3 +186,6 @@ api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_branches.router, dependencies=_pro_deps)
|
||||
api_router.include_router(session_handoffs.router, dependencies=_pro_deps)
|
||||
api_router.include_router(device_types.router, dependencies=_tenant_deps)
|
||||
# L1 is a separate seat-counted SKU; subscription gating is enforced by
|
||||
# seat_enforcement (engineer + l1_seat_limit), not require_active_subscription.
|
||||
api_router.include_router(l1.router, dependencies=_tenant_deps)
|
||||
|
||||
@@ -13,13 +13,20 @@ async def log_audit(
|
||||
resource_id: Optional[UUID] = None,
|
||||
details: Optional[dict] = None,
|
||||
account_id: Optional[UUID] = None,
|
||||
acting_as: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record an audit log entry. Does not commit — piggybacks on the caller's commit."""
|
||||
"""Record an audit log entry. Does not commit — caller's commit picks it up.
|
||||
|
||||
acting_as: optional tag from the session (e.g. 'l1_coverage' for engineers
|
||||
on the L1 surface, None for native l1_tech users).
|
||||
"""
|
||||
if account_id is None:
|
||||
# Derive from the acting user's account as a fallback (one extra query).
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
result = await db.execute(select(User.account_id).where(User.id == user_id))
|
||||
result = await db.execute(
|
||||
select(User.account_id).where(User.id == user_id)
|
||||
)
|
||||
account_id = result.scalar_one()
|
||||
|
||||
entry = AuditLog(
|
||||
@@ -29,5 +36,6 @@ async def log_audit(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details=details,
|
||||
acting_as=acting_as,
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
Centralized permission checks for ResolutionFlow.
|
||||
|
||||
Role hierarchy: super_admin > owner > engineer > viewer
|
||||
Role hierarchy: super_admin > owner > engineer > l1_tech > viewer
|
||||
|
||||
- super_admin: is_super_admin=True, full system access
|
||||
- owner: account_role='owner', manage account resources
|
||||
- engineer: account_role='engineer' (default), CRUD own trees/steps
|
||||
- l1_tech: account_role='l1_tech', use /l1/* surface only — walk flows, resolve/escalate
|
||||
- viewer: account_role='viewer', read-only (can browse, run sessions, rate steps)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +24,8 @@ ROLE_HIERARCHY = {
|
||||
"super_admin": 4,
|
||||
"owner": 3,
|
||||
"engineer": 2,
|
||||
"viewer": 1,
|
||||
"l1_tech": 1,
|
||||
"viewer": 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -221,6 +221,18 @@ async def lifespan(app: FastAPI):
|
||||
max_instances=1,
|
||||
)
|
||||
|
||||
# L1 walk session cleanup: flip stale active sessions to 'abandoned' (hourly)
|
||||
from app.services.l1_session_cleanup import run_cleanup_job as l1_cleanup_run
|
||||
scheduler.add_job(
|
||||
l1_cleanup_run,
|
||||
trigger="interval",
|
||||
hours=1,
|
||||
id="l1_session_cleanup",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
args=[async_session_maker],
|
||||
)
|
||||
|
||||
# Auto-seed trees in background on PR environments
|
||||
seed_task = None
|
||||
if settings.SEED_ON_DEPLOY:
|
||||
|
||||
@@ -66,6 +66,8 @@ from .oauth_identity import OAuthIdentity # noqa: F401
|
||||
from .plan_billing import PlanBilling # noqa: F401
|
||||
from .sales_lead import SalesLead # noqa: F401
|
||||
from .stripe_event import StripeEvent # noqa: F401
|
||||
from .internal_ticket import InternalTicket # noqa: F401
|
||||
from .l1_walk_session import L1WalkSession # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -146,4 +148,6 @@ __all__ = [
|
||||
"PlanBilling",
|
||||
"SalesLead",
|
||||
"StripeEvent",
|
||||
"InternalTicket",
|
||||
"L1WalkSession",
|
||||
]
|
||||
|
||||
@@ -57,6 +57,11 @@ class Account(Base):
|
||||
team_size_bucket: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
primary_psa: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# L1 workspace seats
|
||||
l1_seats_purchased: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0"
|
||||
)
|
||||
|
||||
# SSO / SAML groundwork (Task 11)
|
||||
sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # "saml" | "oidc"
|
||||
|
||||
@@ -35,6 +35,7 @@ class AuditLog(Base):
|
||||
)
|
||||
details: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
acting_as: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
|
||||
@@ -7,7 +7,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, CheckConstraint
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, Boolean, CheckConstraint, text as sa_text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
@@ -48,6 +48,14 @@ class FlowProposal(Base):
|
||||
"status IN ('pending', 'approved', 'modified', 'rejected', 'dismissed', 'auto_reinforced')",
|
||||
name="ck_flow_proposals_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"source IN ('ai_realtime_l1', 'kb_accelerator', 'manual_draft', 'ai_promoted')",
|
||||
name="ck_flow_proposals_source",
|
||||
),
|
||||
CheckConstraint(
|
||||
"linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')",
|
||||
name="ck_flow_proposals_linked_ticket_kind",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
@@ -135,6 +143,16 @@ class FlowProposal(Base):
|
||||
comment="The flow that was created/updated when this proposal was approved",
|
||||
)
|
||||
|
||||
# ── L1 workspace ──
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, server_default=sa_text("'manual_draft'"),
|
||||
)
|
||||
linked_ticket_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
linked_ticket_kind: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
validated_by_outcome: Mapped[bool] = mapped_column(
|
||||
Boolean(), nullable=False, server_default=sa_text('false'),
|
||||
)
|
||||
|
||||
# ── Timestamps ──
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
|
||||
117
backend/app/models/internal_ticket.py
Normal file
117
backend/app/models/internal_ticket.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Internal ticket model.
|
||||
|
||||
Fallback ticket table for L1 intake when the account has no PSA integration.
|
||||
Tracks the customer-facing problem, resolution lifecycle, and optional links
|
||||
to a flow, flow proposal, AI session, and assigned engineer.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, CheckConstraint
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
from app.models.ai_session import AISession
|
||||
|
||||
|
||||
class InternalTicket(Base):
|
||||
"""A fallback support ticket for accounts without a PSA integration.
|
||||
|
||||
status lifecycle:
|
||||
- open: Submitted, not yet picked up.
|
||||
- walking: L1 technician is actively walking the flow.
|
||||
- resolved: Issue resolved; resolution_notes captured.
|
||||
- escalated: Could not resolve; requires higher-tier intervention.
|
||||
"""
|
||||
__tablename__ = "internal_tickets"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('open', 'walking', 'resolved', 'escalated')",
|
||||
name="ck_internal_tickets_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_by_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# ── Customer info ──
|
||||
customer_name: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
|
||||
customer_contact: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
problem_statement: Mapped[str] = mapped_column(Text(), nullable=False)
|
||||
|
||||
# ── Lifecycle ──
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, server_default=sa_text("'open'"), index=True,
|
||||
)
|
||||
|
||||
# ── Optional links ──
|
||||
flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
flow_proposal_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("flow_proposals.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
ai_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_sessions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
assigned_user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ── Resolution ──
|
||||
resolution_notes: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
|
||||
psa_promoted_ticket_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), nullable=True,
|
||||
comment="External PSA ticket ID when this ticket is promoted to a PSA system",
|
||||
)
|
||||
|
||||
# ── Timestamps ──
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
# ── Relationships ──
|
||||
account: Mapped["Account"] = relationship("Account")
|
||||
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id])
|
||||
assigned_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_user_id])
|
||||
flow: Mapped[Optional["Tree"]] = relationship("Tree")
|
||||
flow_proposal: Mapped[Optional["FlowProposal"]] = relationship("FlowProposal")
|
||||
ai_session: Mapped[Optional["AISession"]] = relationship("AISession")
|
||||
141
backend/app/models/l1_walk_session.py
Normal file
141
backend/app/models/l1_walk_session.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""L1 walk session model.
|
||||
|
||||
Per-session state for an L1 technician walking a ticket through a flow,
|
||||
flow proposal, or ad-hoc investigation. Tracks the walked path, notes
|
||||
captured at each step, and terminal resolution / escalation metadata.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey, CheckConstraint
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
|
||||
|
||||
class L1WalkSession(Base):
|
||||
"""A single L1 technician session walking a ticket.
|
||||
|
||||
session_kind values:
|
||||
- flow: Walking a published flow (flow_id required, flow_proposal_id null).
|
||||
- proposal: Walking a draft flow proposal (flow_proposal_id required, flow_id null).
|
||||
- adhoc: Free-form investigation (both flow_id and flow_proposal_id null).
|
||||
|
||||
status lifecycle:
|
||||
- active: Session is in progress.
|
||||
- resolved: Issue resolved; resolution_notes captured.
|
||||
- escalated: Could not resolve; escalation_reason captured.
|
||||
- abandoned: Session exited without resolution or explicit escalation.
|
||||
"""
|
||||
|
||||
__tablename__ = "l1_walk_sessions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"ticket_kind IN ('psa', 'internal')",
|
||||
name="ck_l1_walk_sessions_ticket_kind",
|
||||
),
|
||||
CheckConstraint(
|
||||
"session_kind IN ('flow', 'proposal', 'adhoc')",
|
||||
name="ck_l1_walk_sessions_session_kind",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('active', 'resolved', 'escalated', 'abandoned')",
|
||||
name="ck_l1_walk_sessions_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
|
||||
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
|
||||
"OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)",
|
||||
name="ck_l1_walk_sessions_target_consistency",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_by_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ── Actor context ──
|
||||
acting_as: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
|
||||
|
||||
# ── Ticket reference ──
|
||||
ticket_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
ticket_kind: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
|
||||
# ── Session kind + target ──
|
||||
session_kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
flow_proposal_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("flow_proposals.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# ── Navigation state ──
|
||||
current_node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
walked_path: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"),
|
||||
)
|
||||
walk_notes: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"),
|
||||
)
|
||||
|
||||
# ── Lifecycle ──
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, server_default=sa_text("'active'"), index=True,
|
||||
)
|
||||
|
||||
# ── Resolution ──
|
||||
resolution_notes: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
|
||||
helpful: Mapped[Optional[bool]] = mapped_column(Boolean(), nullable=True)
|
||||
|
||||
# ── Escalation ──
|
||||
escalation_reason: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
|
||||
escalation_reason_category: Mapped[Optional[str]] = mapped_column(
|
||||
String(30), nullable=True,
|
||||
)
|
||||
|
||||
# ── Timestamps ──
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
last_step_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
index=True,
|
||||
)
|
||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
# ── Relationships ──
|
||||
account: Mapped["Account"] = relationship("Account")
|
||||
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id])
|
||||
flow: Mapped[Optional["Tree"]] = relationship("Tree")
|
||||
flow_proposal: Mapped[Optional["FlowProposal"]] = relationship("FlowProposal")
|
||||
@@ -21,6 +21,7 @@ class Subscription(Base):
|
||||
billing_interval: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False, default="active")
|
||||
seat_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
l1_seat_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
current_period_start: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
current_period_end: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cancel_at_period_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text, Integer
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text, Integer, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
@@ -22,7 +22,7 @@ class User(Base):
|
||||
name='ck_users_role_enum'
|
||||
),
|
||||
CheckConstraint(
|
||||
"account_role IN ('owner', 'admin', 'engineer', 'viewer')",
|
||||
"account_role IN ('owner', 'admin', 'engineer', 'l1_tech', 'viewer')",
|
||||
name='ck_users_account_role_enum'
|
||||
),
|
||||
)
|
||||
@@ -50,6 +50,9 @@ class User(Base):
|
||||
index=True
|
||||
)
|
||||
account_role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
||||
can_cover_l1: Mapped[bool] = mapped_column(
|
||||
Boolean(), nullable=False, server_default=text('false')
|
||||
)
|
||||
|
||||
# Legacy team columns (kept for PR A coexistence)
|
||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
|
||||
@@ -27,7 +27,7 @@ class TransferOwnershipRequest(BaseModel):
|
||||
|
||||
class AccountInviteCreate(BaseModel):
|
||||
email: str = Field(..., max_length=255)
|
||||
role: str = Field("engineer", pattern="^(engineer|viewer)$")
|
||||
role: str = Field("engineer", pattern="^(engineer|viewer|l1_tech)$")
|
||||
expires_in_days: Optional[int] = Field(None, ge=1, le=30)
|
||||
|
||||
|
||||
|
||||
72
backend/app/schemas/l1.py
Normal file
72
backend/app/schemas/l1.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Pydantic schemas for the /l1/* endpoint surface."""
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class IntakeRequest(BaseModel):
|
||||
problem_statement: str = Field(..., min_length=1)
|
||||
customer_name: Optional[str] = None
|
||||
customer_contact: Optional[str] = None
|
||||
flow_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class IntakeResponse(BaseModel):
|
||||
session_id: UUID
|
||||
session_kind: Literal["flow", "proposal", "adhoc"]
|
||||
ticket_id: str
|
||||
ticket_kind: Literal["psa", "internal"]
|
||||
|
||||
|
||||
class StepRequest(BaseModel):
|
||||
node_id: str
|
||||
question: str
|
||||
answer: str
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class NotesRequest(BaseModel):
|
||||
notes: list[dict[str, Any]]
|
||||
|
||||
|
||||
class ResolveRequest(BaseModel):
|
||||
helpful: bool
|
||||
resolution_notes: str
|
||||
|
||||
|
||||
class EscalateRequest(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
reason_category: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class EscalateWithoutWalkRequest(BaseModel):
|
||||
problem_statement: str = Field(..., min_length=1)
|
||||
customer_name: Optional[str] = None
|
||||
customer_contact: Optional[str] = None
|
||||
reason_category: str = Field(..., min_length=1)
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class WalkSessionResponse(BaseModel):
|
||||
id: UUID
|
||||
session_kind: str
|
||||
flow_id: Optional[UUID]
|
||||
flow_proposal_id: Optional[UUID]
|
||||
current_node_id: Optional[str]
|
||||
walked_path: list[dict[str, Any]]
|
||||
walk_notes: list[dict[str, Any]]
|
||||
status: str
|
||||
started_at: datetime
|
||||
last_step_at: datetime
|
||||
resolved_at: Optional[datetime]
|
||||
|
||||
|
||||
class QueueRow(BaseModel):
|
||||
ticket_id: str
|
||||
ticket_kind: Literal["psa", "internal"]
|
||||
problem_statement: Optional[str] = None
|
||||
customer_name: Optional[str] = None
|
||||
status: str
|
||||
created_at: Optional[datetime] = None
|
||||
18
backend/app/schemas/seat_enforcement.py
Normal file
18
backend/app/schemas/seat_enforcement.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
Role = Literal['engineer', 'l1_tech']
|
||||
|
||||
|
||||
class SeatCheckResult(BaseModel):
|
||||
available: bool
|
||||
current: int
|
||||
limit: Optional[int] # None = unlimited
|
||||
role: Role
|
||||
|
||||
|
||||
class SeatUsage(BaseModel):
|
||||
engineer: SeatCheckResult
|
||||
l1_tech: SeatCheckResult
|
||||
@@ -60,6 +60,7 @@ class UserResponse(UserBase):
|
||||
email_verified_at: Optional[datetime] = None
|
||||
onboarding_step_completed: Optional[int] = None
|
||||
onboarding_dismissed: bool = False
|
||||
can_cover_l1: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -72,4 +73,8 @@ class RoleUpdate(BaseModel):
|
||||
class AccountRoleUpdate(BaseModel):
|
||||
# Ownership changes must go through the explicit transfer-ownership flow so
|
||||
# account.owner_id stays consistent with user.account_role.
|
||||
account_role: str = Field(..., pattern="^(admin|engineer|viewer)$")
|
||||
account_role: str = Field(..., pattern="^(admin|engineer|viewer|l1_tech)$")
|
||||
|
||||
|
||||
class CoverageUpdate(BaseModel):
|
||||
can_cover_l1: bool
|
||||
|
||||
90
backend/app/services/internal_ticket_service.py
Normal file
90
backend/app/services/internal_ticket_service.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""CRUD + status transitions for internal_tickets (the no-PSA fallback ticket model)."""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.internal_ticket import InternalTicket
|
||||
|
||||
|
||||
async def create_ticket(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
account_id: UUID,
|
||||
created_by_user_id: UUID,
|
||||
problem_statement: str,
|
||||
customer_name: Optional[str] = None,
|
||||
customer_contact: Optional[str] = None,
|
||||
) -> InternalTicket:
|
||||
"""Create a new internal ticket in 'open' status."""
|
||||
ticket = InternalTicket(
|
||||
account_id=account_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
problem_statement=problem_statement,
|
||||
customer_name=customer_name,
|
||||
customer_contact=customer_contact,
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
return ticket
|
||||
|
||||
|
||||
async def update_status(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
ticket_id: UUID,
|
||||
status: str,
|
||||
resolution_notes: Optional[str] = None,
|
||||
assigned_user_id: Optional[UUID] = None,
|
||||
) -> InternalTicket:
|
||||
"""Transition a ticket to a new status. Sets resolved_at when status='resolved'."""
|
||||
ticket = await db.get(InternalTicket, ticket_id)
|
||||
if not ticket:
|
||||
raise ValueError(f"InternalTicket {ticket_id} not found")
|
||||
ticket.status = status
|
||||
if status == 'resolved':
|
||||
ticket.resolved_at = datetime.now(timezone.utc)
|
||||
if resolution_notes is not None:
|
||||
ticket.resolution_notes = resolution_notes
|
||||
if assigned_user_id is not None:
|
||||
ticket.assigned_user_id = assigned_user_id
|
||||
await db.flush()
|
||||
return ticket
|
||||
|
||||
|
||||
async def get_ticket(db: AsyncSession, *, ticket_id: UUID) -> Optional[InternalTicket]:
|
||||
"""Fetch a ticket by ID. Returns None if not found."""
|
||||
return await db.get(InternalTicket, ticket_id)
|
||||
|
||||
|
||||
async def list_tickets_for_account(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
account_id: UUID,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> list[InternalTicket]:
|
||||
"""List tickets for an account, optionally filtered by status, newest first."""
|
||||
stmt = select(InternalTicket).where(InternalTicket.account_id == account_id)
|
||||
if status:
|
||||
stmt = stmt.where(InternalTicket.status == status)
|
||||
stmt = stmt.order_by(InternalTicket.created_at.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
async def promote_to_psa(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
ticket_id: UUID,
|
||||
psa_ticket_id: str,
|
||||
) -> InternalTicket:
|
||||
"""Mark an internal ticket as promoted to PSA."""
|
||||
ticket = await db.get(InternalTicket, ticket_id)
|
||||
if not ticket:
|
||||
raise ValueError(f"InternalTicket {ticket_id} not found")
|
||||
ticket.psa_promoted_ticket_id = psa_ticket_id
|
||||
await db.flush()
|
||||
return ticket
|
||||
49
backend/app/services/l1_session_cleanup.py
Normal file
49
backend/app/services/l1_session_cleanup.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Hourly cleanup job: flip stale active L1WalkSessions to 'abandoned'.
|
||||
|
||||
Sessions with status='active' and last_step_at older than 24h are considered
|
||||
abandoned (L1 closed the browser, customer hung up, etc.). Flipping them
|
||||
removes them from the "Resume in progress" widget while preserving the row
|
||||
for audit/reporting.
|
||||
|
||||
Run via APScheduler interval job, max_instances=1 (Lesson 1).
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def flip_stale_sessions(db: AsyncSession) -> int:
|
||||
"""Flip active sessions to 'abandoned' if last_step_at < now - 24h.
|
||||
|
||||
Returns the number of sessions flipped.
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
stmt = (
|
||||
update(L1WalkSession)
|
||||
.where(L1WalkSession.status == "active")
|
||||
.where(L1WalkSession.last_step_at < cutoff)
|
||||
.values(status="abandoned")
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
await db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def run_cleanup_job(session_factory) -> None:
|
||||
"""APScheduler entry point. Uses the admin session factory (no RLS context)."""
|
||||
async with session_factory() as db:
|
||||
try:
|
||||
count = await flip_stale_sessions(db)
|
||||
if count > 0:
|
||||
logger.info(
|
||||
"l1_session_cleanup: flipped %d sessions to abandoned", count
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("l1_session_cleanup: error during run")
|
||||
321
backend/app/services/l1_session_service.py
Normal file
321
backend/app/services/l1_session_service.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""L1 session lifecycle: start (flow/proposal/adhoc), step, notes, resolve, escalate.
|
||||
|
||||
start_* functions live in T12; step/notes are T13; resolve/escalate are T14.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
from app.models.user import User
|
||||
from app.services import internal_ticket_service
|
||||
|
||||
|
||||
def _resolve_acting_as(user: User) -> Optional[str]:
|
||||
"""An engineer (whether covering or not) gets tagged for audit when using L1 surface.
|
||||
|
||||
Returns 'l1_coverage' for engineers (only engineers WITH the coverage flag should
|
||||
reach this code path — the require_l1_or_coverage dep gates that). For native
|
||||
l1_tech users, returns None (no special tag — they ARE l1).
|
||||
"""
|
||||
if user.account_role == "engineer":
|
||||
return "l1_coverage"
|
||||
return None
|
||||
|
||||
|
||||
async def start_flow_session(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
account_id: UUID,
|
||||
user: User,
|
||||
flow_id: UUID,
|
||||
ticket_id: str,
|
||||
ticket_kind: str, # 'psa' | 'internal'
|
||||
) -> L1WalkSession:
|
||||
"""Start a session walking an authored flow."""
|
||||
session = L1WalkSession(
|
||||
account_id=account_id,
|
||||
created_by_user_id=user.id,
|
||||
acting_as=_resolve_acting_as(user),
|
||||
ticket_id=ticket_id,
|
||||
ticket_kind=ticket_kind,
|
||||
session_kind="flow",
|
||||
flow_id=flow_id,
|
||||
)
|
||||
db.add(session)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def start_proposal_session(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
account_id: UUID,
|
||||
user: User,
|
||||
flow_proposal_id: UUID,
|
||||
ticket_id: str,
|
||||
ticket_kind: str,
|
||||
) -> L1WalkSession:
|
||||
"""Start a session walking an AI-built FlowProposal."""
|
||||
session = L1WalkSession(
|
||||
account_id=account_id,
|
||||
created_by_user_id=user.id,
|
||||
acting_as=_resolve_acting_as(user),
|
||||
ticket_id=ticket_id,
|
||||
ticket_kind=ticket_kind,
|
||||
session_kind="proposal",
|
||||
flow_proposal_id=flow_proposal_id,
|
||||
)
|
||||
db.add(session)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def start_adhoc_session(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
account_id: UUID,
|
||||
user: User,
|
||||
ticket_id: str,
|
||||
ticket_kind: str,
|
||||
) -> L1WalkSession:
|
||||
"""Start an ad-hoc session with no tree (free-form note-taking only)."""
|
||||
session = L1WalkSession(
|
||||
account_id=account_id,
|
||||
created_by_user_id=user.id,
|
||||
acting_as=_resolve_acting_as(user),
|
||||
ticket_id=ticket_id,
|
||||
ticket_kind=ticket_kind,
|
||||
session_kind="adhoc",
|
||||
)
|
||||
db.add(session)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def record_step(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_id: UUID,
|
||||
node_id: str,
|
||||
question: str,
|
||||
answer: str,
|
||||
note: Optional[str] = None,
|
||||
) -> L1WalkSession:
|
||||
"""Record an answered step in a tree walk. Appends to walked_path JSONB and
|
||||
advances current_node_id. Raises ValueError on adhoc sessions or inactive
|
||||
sessions. Updates last_step_at."""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if not session:
|
||||
raise ValueError(f"L1WalkSession {session_id} not found")
|
||||
if session.session_kind == "adhoc":
|
||||
raise ValueError("Cannot record step on adhoc session — use update_notes")
|
||||
if session.status != "active":
|
||||
raise ValueError(f"Session {session_id} is not active (status={session.status})")
|
||||
entry = {
|
||||
"node_id": node_id,
|
||||
"question": question,
|
||||
"answer": answer,
|
||||
"l1_note": note,
|
||||
}
|
||||
# JSONB requires assigning a new list — in-place mutation isn't tracked
|
||||
session.walked_path = [*session.walked_path, entry]
|
||||
session.current_node_id = node_id
|
||||
session.last_step_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def update_notes(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_id: UUID,
|
||||
notes: list[dict],
|
||||
) -> L1WalkSession:
|
||||
"""Replace walk_notes on an active session. Used by adhoc walks for
|
||||
debounced autosave. Raises ValueError if missing or inactive. Caps notes
|
||||
payload at 256KB to prevent unbounded growth."""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if not session:
|
||||
raise ValueError(f"L1WalkSession {session_id} not found")
|
||||
if session.status != "active":
|
||||
raise ValueError(f"Session {session_id} is not active (status={session.status})")
|
||||
encoded_size = len(json.dumps(notes).encode("utf-8"))
|
||||
if encoded_size > 256 * 1024:
|
||||
raise ValueError("walk_notes exceeds 256KB cap — consider escalating")
|
||||
session.walk_notes = notes
|
||||
session.last_step_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def resolve(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_id: UUID,
|
||||
helpful: bool,
|
||||
resolution_notes: str,
|
||||
) -> L1WalkSession:
|
||||
"""Close a session as resolved.
|
||||
|
||||
- Sets status='resolved', helpful, resolution_notes, resolved_at.
|
||||
- On helpful=True AND session_kind='proposal': flips
|
||||
flow_proposal.validated_by_outcome=True (one-bit aggregate signal).
|
||||
- Closes the linked internal ticket (PSA close stubbed for Phase 2).
|
||||
- Raises ValueError on missing or non-active session.
|
||||
"""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if not session:
|
||||
raise ValueError(f"L1WalkSession {session_id} not found")
|
||||
if session.status != "active":
|
||||
raise ValueError(f"Session not active (status={session.status})")
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "resolved"
|
||||
session.helpful = helpful
|
||||
session.resolution_notes = resolution_notes
|
||||
session.resolved_at = now
|
||||
session.last_step_at = now
|
||||
|
||||
if helpful and session.session_kind == "proposal" and session.flow_proposal_id:
|
||||
proposal = await db.get(FlowProposal, session.flow_proposal_id)
|
||||
if proposal:
|
||||
proposal.validated_by_outcome = True
|
||||
|
||||
if session.ticket_kind == "internal":
|
||||
await internal_ticket_service.update_status(
|
||||
db,
|
||||
ticket_id=UUID(session.ticket_id),
|
||||
status="resolved",
|
||||
resolution_notes=resolution_notes,
|
||||
)
|
||||
# PSA close deferred to Phase 2 — no-op for now
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
user_id=session.created_by_user_id,
|
||||
action="l1.session.resolve",
|
||||
resource_type="l1_walk_session",
|
||||
resource_id=session.id,
|
||||
details={
|
||||
"session_kind": session.session_kind,
|
||||
"helpful": helpful,
|
||||
"ticket_id": session.ticket_id,
|
||||
"ticket_kind": session.ticket_kind,
|
||||
},
|
||||
account_id=session.account_id,
|
||||
acting_as=session.acting_as,
|
||||
)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def escalate(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_id: UUID,
|
||||
reason: str,
|
||||
reason_category: str,
|
||||
) -> L1WalkSession:
|
||||
"""Escalate an active session to engineering.
|
||||
|
||||
- Sets status='escalated', escalation_reason, escalation_reason_category, resolved_at.
|
||||
- Marks the linked internal ticket as escalated (PSA reassign deferred to Phase 2).
|
||||
- Raises ValueError on missing or non-active session.
|
||||
"""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if not session:
|
||||
raise ValueError(f"L1WalkSession {session_id} not found")
|
||||
if session.status != "active":
|
||||
raise ValueError(f"Session not active (status={session.status})")
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "escalated"
|
||||
session.escalation_reason = reason
|
||||
session.escalation_reason_category = reason_category
|
||||
session.resolved_at = now
|
||||
session.last_step_at = now
|
||||
|
||||
if session.ticket_kind == "internal":
|
||||
await internal_ticket_service.update_status(
|
||||
db,
|
||||
ticket_id=UUID(session.ticket_id),
|
||||
status="escalated",
|
||||
)
|
||||
# PSA reassign deferred to Phase 2
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
user_id=session.created_by_user_id,
|
||||
action="l1.session.escalate",
|
||||
resource_type="l1_walk_session",
|
||||
resource_id=session.id,
|
||||
details={
|
||||
"session_kind": session.session_kind,
|
||||
"escalation_reason_category": reason_category,
|
||||
"ticket_id": session.ticket_id,
|
||||
"ticket_kind": session.ticket_kind,
|
||||
},
|
||||
account_id=session.account_id,
|
||||
acting_as=session.acting_as,
|
||||
)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def escalate_without_walk(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
account_id: UUID,
|
||||
user: User,
|
||||
ticket_id: str,
|
||||
ticket_kind: str,
|
||||
reason_category: str,
|
||||
reason: Optional[str] = None,
|
||||
) -> L1WalkSession:
|
||||
"""Create an immediately-escalated session with no walked_path.
|
||||
|
||||
Used from the BuildAbortedNoKB screen (no KB content available to walk a
|
||||
tree). Captures the call as an audit record + escalates the ticket without
|
||||
requiring a walker session in between.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
session = L1WalkSession(
|
||||
account_id=account_id,
|
||||
created_by_user_id=user.id,
|
||||
acting_as=_resolve_acting_as(user),
|
||||
ticket_id=ticket_id,
|
||||
ticket_kind=ticket_kind,
|
||||
session_kind="adhoc",
|
||||
status="escalated",
|
||||
escalation_reason=reason,
|
||||
escalation_reason_category=reason_category,
|
||||
resolved_at=now,
|
||||
last_step_at=now,
|
||||
)
|
||||
db.add(session)
|
||||
if ticket_kind == "internal":
|
||||
await internal_ticket_service.update_status(
|
||||
db,
|
||||
ticket_id=UUID(ticket_id),
|
||||
status="escalated",
|
||||
)
|
||||
await db.flush() # flush first so session.id is populated
|
||||
await log_audit(
|
||||
db,
|
||||
user_id=session.created_by_user_id,
|
||||
action="l1.session.escalate_no_walk",
|
||||
resource_type="l1_walk_session",
|
||||
resource_id=session.id,
|
||||
details={
|
||||
"escalation_reason_category": reason_category,
|
||||
"ticket_id": ticket_id,
|
||||
"ticket_kind": ticket_kind,
|
||||
},
|
||||
account_id=session.account_id,
|
||||
acting_as=session.acting_as,
|
||||
)
|
||||
return session
|
||||
63
backend/app/services/seat_enforcement.py
Normal file
63
backend/app/services/seat_enforcement.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.user import User
|
||||
from app.schemas.seat_enforcement import SeatCheckResult
|
||||
|
||||
|
||||
Role = Literal['engineer', 'l1_tech']
|
||||
|
||||
|
||||
def _limit_for_role(subscription: Subscription, role: Role) -> int | None:
|
||||
if role == 'engineer':
|
||||
return subscription.seat_limit
|
||||
if role == 'l1_tech':
|
||||
return subscription.l1_seat_limit
|
||||
raise ValueError(f"Unknown role: {role}")
|
||||
|
||||
|
||||
async def check_seat_available(
|
||||
account: Account,
|
||||
subscription: Subscription,
|
||||
role: Role,
|
||||
db: AsyncSession,
|
||||
) -> SeatCheckResult:
|
||||
"""
|
||||
Count active users with the given role in the account, compare against
|
||||
the role-specific seat limit on the subscription. Returns availability.
|
||||
|
||||
None limit = unlimited (returns available=True).
|
||||
"""
|
||||
limit = _limit_for_role(subscription, role)
|
||||
|
||||
stmt = (
|
||||
select(func.count(User.id))
|
||||
.where(User.account_id == account.id)
|
||||
.where(User.account_role == role)
|
||||
.where(User.is_active.is_(True))
|
||||
)
|
||||
current = (await db.execute(stmt)).scalar_one()
|
||||
|
||||
if limit is None:
|
||||
return SeatCheckResult(available=True, current=current, limit=None, role=role)
|
||||
return SeatCheckResult(
|
||||
available=current < limit,
|
||||
current=current,
|
||||
limit=limit,
|
||||
role=role,
|
||||
)
|
||||
|
||||
|
||||
async def get_seat_usage(
|
||||
account: Account,
|
||||
subscription: Subscription,
|
||||
db: AsyncSession,
|
||||
) -> tuple[SeatCheckResult, SeatCheckResult]:
|
||||
"""Return (engineer, l1_tech) seat-usage tuple for the seat-counter widget."""
|
||||
eng = await check_seat_available(account, subscription, 'engineer', db)
|
||||
l1 = await check_seat_available(account, subscription, 'l1_tech', db)
|
||||
return eng, l1
|
||||
Reference in New Issue
Block a user