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:
Michael Chihlas
2026-03-14 23:46:21 -04:00
parent 540208a923
commit 1771577732
3 changed files with 220 additions and 0 deletions

View File

@@ -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:

View File

@@ -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",
]

View File

@@ -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