fix(psa): fix time entry AttributeError and show all users in member mapping

- Fix create_time_entry() using self._client instead of self.client
- GET /member-mappings now returns all active account users, not just mapped
  ones — allows manual assignment when auto-match by email doesn't work
- PsaMemberMappingResponse mapping fields are now Optional (id, external_member_id,
  external_member_name, matched_by) to represent unmapped users
- Frontend MemberMappingTab skips null external_member_id when building
  localMappings, and derives user list from all returned entries
- Add docs/connectwise-psa-testing-checklist.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-14 06:09:01 +00:00
parent b433b232dc
commit 8eb814283d
6 changed files with 197 additions and 34 deletions

View File

@@ -517,31 +517,37 @@ 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."""
"""Get all account users with their PSA member mappings (unmapped users included)."""
conn = await _get_account_connection(current_user.account_id, db)
if not conn:
return []
result = await db.execute(
# Fetch all active 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()
# Fetch all existing mappings keyed by user_id for O(1) lookup
mappings_result = await db.execute(
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
mappings = result.scalars().all()
mapping_by_user: dict[str, PsaMemberMapping] = {
str(m.user_id): m for m in 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
return [
PsaMemberMappingResponse(
id=str(m.id) if (m := mapping_by_user.get(str(user.id))) else None,
user_id=str(user.id),
user_email=user.email,
user_name=user.name,
external_member_id=m.external_member_id if m else None,
external_member_name=m.external_member_name if m else None,
matched_by=m.matched_by if m else None,
)
for user in users
]
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])

View File

@@ -111,13 +111,13 @@ class PsaPostLogResponse(BaseModel):
class PsaMemberMappingResponse(BaseModel):
id: str
id: str | None = None # None for users without a mapping
user_id: str
user_email: str
user_name: str
external_member_id: str
external_member_name: str
matched_by: str
external_member_id: str | None = None
external_member_name: str | None = None
matched_by: str | None = None
class PsaMemberMappingSaveRequest(BaseModel):

View File

@@ -536,7 +536,7 @@ class ConnectWiseProvider(PSAProvider):
if work_type:
payload["workType"] = {"name": work_type}
data = await self._client.post("/time/entries", payload)
data = await self.client.post("/time/entries", payload)
return PSATimeEntry(
id=str(data["id"]),
ticket_id=ticket_id,