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 import select
from sqlalchemy.ext.asyncio import AsyncSession 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.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
from app.core.database import get_db from app.core.database import get_db
from app.models.psa_connection import PsaConnection from app.models.psa_connection import PsaConnection
from app.models.psa_member_mapping import PsaMemberMapping
from app.models.user import User from app.models.user import User
from app.schemas.psa_connection import ( from app.schemas.psa_connection import (
PsaConnectionCreate, PsaConnectionCreate,
@@ -20,6 +23,10 @@ from app.schemas.psa_connection import (
PsaConnectionUpdate, PsaConnectionUpdate,
PSATicketSearchResult, PSATicketSearchResult,
PSATicketStatusItem, PSATicketStatusItem,
PsaMemberMappingResponse,
PsaMemberMappingSaveRequest,
PsaMemberResponse,
AutoMatchResult,
) )
from app.services.psa.encryption import ( from app.services.psa.encryption import (
decrypt_credentials, decrypt_credentials,
@@ -358,8 +365,188 @@ async def get_ticket_statuses(
raise HTTPException(status_code=502, detail=str(e)) 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 ───────────────────────────────────────────────── # ── 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( async def _get_connection_or_404(
connection_id: UUID, user: User, db: AsyncSession connection_id: UUID, user: User, db: AsyncSession
) -> PsaConnection: ) -> PsaConnection:

View File

@@ -19,6 +19,7 @@ from .psa_connection import (
PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse,
PSATicketSearchResult, PSATicketStatusItem, PSATicketSearchResult, PSATicketStatusItem,
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse, PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
) )
__all__ = [ __all__ = [
@@ -48,4 +49,5 @@ __all__ = [
"PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse", "PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse",
"PSATicketSearchResult", "PSATicketStatusItem", "PSATicketSearchResult", "PSATicketStatusItem",
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse", "PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
] ]

View File

@@ -106,3 +106,34 @@ class PsaPostLogResponse(BaseModel):
status_changed_to: str | None = None status_changed_to: str | None = None
posted_at: str posted_at: str
content_preview: str # first 200 chars 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