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 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:
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user