feat(psa): add member mapping CRUD and auto-match endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,12 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
||||
from app.core.database import get_db
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.psa_member_mapping import PsaMemberMapping
|
||||
from app.models.user import User
|
||||
from app.schemas.psa_connection import (
|
||||
PsaConnectionCreate,
|
||||
@@ -20,6 +23,10 @@ from app.schemas.psa_connection import (
|
||||
PsaConnectionUpdate,
|
||||
PSATicketSearchResult,
|
||||
PSATicketStatusItem,
|
||||
PsaMemberMappingResponse,
|
||||
PsaMemberMappingSaveRequest,
|
||||
PsaMemberResponse,
|
||||
AutoMatchResult,
|
||||
)
|
||||
from app.services.psa.encryption import (
|
||||
decrypt_credentials,
|
||||
@@ -358,8 +365,188 @@ async def get_ticket_statuses(
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
# ── member mapping endpoints ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/members", response_model=list[PsaMemberResponse])
|
||||
async def list_members(
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List CW members (from CW API)."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
members = await provider.list_members()
|
||||
return [
|
||||
PsaMemberResponse(id=m.id, identifier=m.identifier, name=m.name, email=m.email)
|
||||
for m in members
|
||||
]
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||
async def get_member_mappings(
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get all member mappings for the account."""
|
||||
conn = await _get_account_connection(current_user.account_id, db)
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
|
||||
)
|
||||
mappings = result.scalars().all()
|
||||
|
||||
response = []
|
||||
for m in mappings:
|
||||
user_result = await db.execute(select(User).where(User.id == m.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
response.append(PsaMemberMappingResponse(
|
||||
id=str(m.id),
|
||||
user_id=str(m.user_id),
|
||||
user_email=user.email,
|
||||
user_name=user.name,
|
||||
external_member_id=m.external_member_id,
|
||||
external_member_name=m.external_member_name,
|
||||
matched_by=m.matched_by,
|
||||
))
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||
async def save_member_mappings(
|
||||
mappings: list[PsaMemberMappingSaveRequest],
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Save/update member mappings (batch). Replaces all existing mappings."""
|
||||
conn = await _get_account_connection(current_user.account_id, db)
|
||||
if not conn:
|
||||
raise HTTPException(status_code=400, detail="No PSA connection configured")
|
||||
|
||||
# Delete existing mappings
|
||||
await db.execute(
|
||||
delete(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
|
||||
)
|
||||
|
||||
# Insert new mappings
|
||||
for m in mappings:
|
||||
mapping = PsaMemberMapping(
|
||||
psa_connection_id=conn.id,
|
||||
user_id=UUID(m.user_id),
|
||||
external_member_id=m.external_member_id,
|
||||
external_member_name=m.external_member_name,
|
||||
matched_by="manual_admin",
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Return the saved mappings
|
||||
return await get_member_mappings(current_user, db)
|
||||
|
||||
|
||||
@router.post("/member-mappings/auto-match", response_model=AutoMatchResult)
|
||||
async def auto_match_members(
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Auto-match RF users to CW members by email."""
|
||||
conn = await _get_account_connection(current_user.account_id, db)
|
||||
if not conn:
|
||||
raise HTTPException(status_code=400, detail="No PSA connection configured")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
cw_members = await provider.list_members()
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
# Build email → member lookup
|
||||
email_to_member: dict = {}
|
||||
for m in cw_members:
|
||||
if m.email:
|
||||
email_to_member[m.email.lower()] = m
|
||||
|
||||
# Get account users
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.account_id == current_user.account_id, User.is_active.is_(True))
|
||||
)
|
||||
users = users_result.scalars().all()
|
||||
|
||||
matched = []
|
||||
unmatched_count = 0
|
||||
|
||||
for user in users:
|
||||
cw_member = email_to_member.get(user.email.lower())
|
||||
if cw_member:
|
||||
# Check if mapping already exists
|
||||
existing = await db.execute(
|
||||
select(PsaMemberMapping).where(
|
||||
PsaMemberMapping.psa_connection_id == conn.id,
|
||||
PsaMemberMapping.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
mapping = PsaMemberMapping(
|
||||
psa_connection_id=conn.id,
|
||||
user_id=user.id,
|
||||
external_member_id=cw_member.id,
|
||||
external_member_name=cw_member.name,
|
||||
matched_by="auto_email",
|
||||
)
|
||||
db.add(mapping)
|
||||
matched.append((mapping, user))
|
||||
else:
|
||||
unmatched_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Build response
|
||||
matched_response = [
|
||||
PsaMemberMappingResponse(
|
||||
id=str(m.id),
|
||||
user_id=str(m.user_id),
|
||||
user_email=u.email,
|
||||
user_name=u.name,
|
||||
external_member_id=m.external_member_id,
|
||||
external_member_name=m.external_member_name,
|
||||
matched_by=m.matched_by,
|
||||
)
|
||||
for m, u in matched
|
||||
]
|
||||
|
||||
return AutoMatchResult(matched=matched_response, unmatched_users=unmatched_count)
|
||||
|
||||
|
||||
# ── internal helpers ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_account_connection(
|
||||
account_id: UUID | None, db: AsyncSession
|
||||
) -> PsaConnection | None:
|
||||
"""Get the PSA connection for an account."""
|
||||
if not account_id:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(PsaConnection).where(PsaConnection.account_id == account_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _get_connection_or_404(
|
||||
connection_id: UUID, user: User, db: AsyncSession
|
||||
) -> PsaConnection:
|
||||
|
||||
@@ -19,6 +19,7 @@ from .psa_connection import (
|
||||
PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse,
|
||||
PSATicketSearchResult, PSATicketStatusItem,
|
||||
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
|
||||
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -48,4 +49,5 @@ __all__ = [
|
||||
"PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse",
|
||||
"PSATicketSearchResult", "PSATicketStatusItem",
|
||||
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
|
||||
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
|
||||
]
|
||||
|
||||
@@ -106,3 +106,34 @@ class PsaPostLogResponse(BaseModel):
|
||||
status_changed_to: str | None = None
|
||||
posted_at: str
|
||||
content_preview: str # first 200 chars
|
||||
|
||||
|
||||
# ── Member mapping schemas ───────────────────────────────────────
|
||||
|
||||
|
||||
class PsaMemberMappingResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_email: str
|
||||
user_name: str
|
||||
external_member_id: str
|
||||
external_member_name: str
|
||||
matched_by: str
|
||||
|
||||
|
||||
class PsaMemberMappingSaveRequest(BaseModel):
|
||||
user_id: str
|
||||
external_member_id: str
|
||||
external_member_name: str
|
||||
|
||||
|
||||
class PsaMemberResponse(BaseModel):
|
||||
id: str
|
||||
identifier: str
|
||||
name: str
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class AutoMatchResult(BaseModel):
|
||||
matched: list[PsaMemberMappingResponse]
|
||||
unmatched_users: int
|
||||
|
||||
Reference in New Issue
Block a user