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