diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index 525ac035..b260fccb 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -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: diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 61c128b5..85067a1d 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", ] diff --git a/backend/app/schemas/psa_connection.py b/backend/app/schemas/psa_connection.py index 97081aa1..5231a201 100644 --- a/backend/app/schemas/psa_connection.py +++ b/backend/app/schemas/psa_connection.py @@ -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