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)], current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)], 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) conn = await _get_account_connection(current_user.account_id, db)
if not conn: if not conn:
return [] 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) 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 = [] return [
for m in mappings: PsaMemberMappingResponse(
user_result = await db.execute(select(User).where(User.id == m.user_id)) id=str(m.id) if (m := mapping_by_user.get(str(user.id))) else None,
user = user_result.scalar_one_or_none() user_id=str(user.id),
if user: user_email=user.email,
response.append(PsaMemberMappingResponse( user_name=user.name,
id=str(m.id), external_member_id=m.external_member_id if m else None,
user_id=str(m.user_id), external_member_name=m.external_member_name if m else None,
user_email=user.email, matched_by=m.matched_by if m else None,
user_name=user.name, )
external_member_id=m.external_member_id, for user in users
external_member_name=m.external_member_name, ]
matched_by=m.matched_by,
))
return response
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse]) @router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])

View File

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

View File

@@ -536,7 +536,7 @@ class ConnectWiseProvider(PSAProvider):
if work_type: if work_type:
payload["workType"] = {"name": 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( return PSATimeEntry(
id=str(data["id"]), id=str(data["id"]),
ticket_id=ticket_id, ticket_id=ticket_id,

View File

@@ -0,0 +1,158 @@
# ConnectWise PSA Integration — Testing Checklist
> **Purpose:** Step-by-step guide to connect ResolutionFlow to a ConnectWise developer sandbox and validate each integration feature end-to-end.
>
> **Date created:** 2026-04-14
> **Branch:** main
---
## Prerequisites
Before starting, make sure you have:
- [ ] ResolutionFlow backend running (`uvicorn app.main:app --reload` from `backend/`)
- [ ] ResolutionFlow frontend running (`npm run dev` from `frontend/`)
- [ ] A ConnectWise developer sandbox account
---
## Step 1 — Get Your ConnectWise Developer Credentials
You need four pieces of information from your ConnectWise sandbox.
**Company ID**
- This is the company name you log in with on the CW login screen (e.g. if your URL is `na.myconnectwise.net` and your login company is `resolutionflow`, the Company ID is `resolutionflow`)
**Site URL**
- Developer sandboxes are typically `na.myconnectwise.net` or `aus.connectwisedev.com`
- Do **not** include `https://` — enter just the hostname (e.g. `na.myconnectwise.net`)
**API Public Key + Private Key**
1. Log into your CW sandbox
2. Go to **System → Members** → open your own member record
3. Click the **API Keys** tab
4. Click **New** → give it a name (e.g. "ResolutionFlow Dev")
5. Save — the **Private Key** is shown only once, copy it now
6. Note both the **Public Key** (shown on the list) and **Private Key**
**Client ID** (already configured server-side)
- The `CW_CLIENT_ID` is set in `backend/app/core/config.py` — this identifies the ResolutionFlow app to ConnectWise and is shared across all tenants. You do not need to enter this in the UI.
---
## Step 2 — Connect ResolutionFlow to ConnectWise
- [ ] Log into ResolutionFlow as a **Team Admin or Super Admin** user
- [ ] Navigate to **Account → Integrations**
- [ ] On the **Connection** tab, fill in the form:
- Display Name: anything (e.g. `CW Dev Sandbox`)
- Site URL: your sandbox hostname (e.g. `na.myconnectwise.net`)
- Company ID: your CW company ID
- Public Key: from Step 1
- Private Key: from Step 1
- [ ] Click **Connect** — the backend tests the credentials before saving
- [ ] Verify: "Connected" status appears with a green dot
- [ ] Click **Test Connection** button and confirm it returns a success message + server version
---
## Step 3 — Member Mapping
Maps ResolutionFlow users to ConnectWise members so that PSA posts are attributed to the right technician.
- [ ] Click the **Member Mapping** tab
- [ ] Click **Auto-Match by Email** — ResolutionFlow matches users to CW members with the same email address
- [ ] Verify the matched count in the toast notification
- [ ] If any users are unmatched, manually assign them via the dropdown
- [ ] Click **Save Mappings** if you made manual changes
---
## Step 4 — Ticket Search (via FlowPilot session)
- [ ] Start a new FlowPilot session (from the Dashboard)
- [ ] Look for the **Link Ticket** button in the session header
- [ ] Search for a ticket by keyword or ticket number
- [ ] Verify: ticket results appear showing summary, board, status, priority
- [ ] Select a ticket and confirm it links to the session
---
## Step 5 — Ticket Context Injection
Once a ticket is linked, FlowPilot should enrich its context with CW data.
- [ ] With a ticket linked, send a message to FlowPilot
- [ ] Verify: FlowPilot's response references ticket details (company name, status, configurations, etc.)
- [ ] Check backend logs to confirm `GET /integrations/psa/tickets/{id}/context` is being called
---
## Step 6 — PSA Post (push session notes to ticket)
This is the core feature — pushing session documentation back to the ConnectWise ticket.
- [ ] In the linked session, click **Update** (or the PSA post button in the session header)
- [ ] Review the **Preview** — confirm the generated content looks correct
- [ ] Select a **Note Type**:
- `Internal Analysis` — internal-only note (visible to techs, not clients)
- `Resolution` — marks as resolved, notifies client
- `Description` — main ticket description note
- [ ] Optionally select a **Status** to update the ticket to (e.g. "In Progress" → "Resolved")
- [ ] Click **Post to Ticket**
- [ ] Verify: success toast appears
- [ ] Verify in ConnectWise: open the ticket and confirm the note was posted with correct content and attribution (your member name)
---
## Step 7 — FlowPilot Settings
Configure how FlowPilot behaves with PSA automation.
- [ ] Go to **Account → Integrations → FlowPilot** tab
- [ ] Review each setting:
- **Auto Push** — automatically post session doc on session close
- **Auto Time Entry** — automatically log hours from session duration
- **Time Rounding** — 15min / 30min / exact / none
- **Note Visibility** — internal only vs. internal + external
- **Include Diagnostic Steps** — whether to include step-by-step notes
- **Prompt Status on Resolution** — ask to update CW status when resolving
- **Prompt Status on Escalation** — ask to update CW status when escalating
- [ ] Adjust to your preference and save
---
## Step 8 — End-to-End Smoke Test
Run a complete session to confirm the full flow works together.
- [ ] Start a new FlowPilot session with a test ticket in CW
- [ ] Link the ticket at session start
- [ ] Work through a troubleshooting flow (even a simple one)
- [ ] Resolve or escalate the session
- [ ] Post the session documentation to the CW ticket
- [ ] Open the ticket in ConnectWise and confirm:
- [ ] Note content is correct and well-formatted
- [ ] Note is attributed to the correct CW member
- [ ] Ticket status was updated (if you chose to update)
- [ ] Duration / time entry was logged (if auto-time-entry is on)
---
## Known Issues / Bugs Fixed
| Bug | Status | Location |
|-----|--------|----------|
| `create_time_entry()` used `self._client` instead of `self.client` | Fixed 2026-04-14 | `services/psa/connectwise/provider.py:539` |
---
## What's NOT Yet Implemented
| Feature | Notes |
|---------|-------|
| Autotask PSA | Schema accepts `autotask` as provider but no implementation exists |
| Retry queue for failed posts | `retry_count` / `next_retry_at` columns exist in DB but no background job |
| `psa_activity_log` population | Table exists, no endpoints write to it yet |
| Post History tab | Currently a placeholder — post history is viewable per-session only |

View File

@@ -648,10 +648,12 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse |
setCwMembers(members) setCwMembers(members)
setMappings(existingMappings) setMappings(existingMappings)
// Build local mapping state from existing mappings // Build local mapping state from existing mappings (skip unmapped entries)
const lookup: Record<string, { external_member_id: string; external_member_name: string }> = {} const lookup: Record<string, { external_member_id: string; external_member_name: string }> = {}
for (const m of existingMappings) { for (const m of existingMappings) {
lookup[m.user_id] = { external_member_id: m.external_member_id, external_member_name: m.external_member_name } if (m.external_member_id) {
lookup[m.user_id] = { external_member_id: m.external_member_id, external_member_name: m.external_member_name ?? '' }
}
} }
setLocalMappings(lookup) setLocalMappings(lookup)
setIsDirty(false) setIsDirty(false)
@@ -716,14 +718,11 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse |
} }
} }
// Derive user list from mappings response (all account users are returned) // All account users — includes both mapped and unmapped
const userRows = mappings.length > 0 const uniqueUsers = hasLoaded
? mappings.map(m => ({ user_id: m.user_id, user_email: m.user_email, user_name: m.user_name, matched_by: m.matched_by })) ? mappings.map(m => ({ user_id: m.user_id, user_email: m.user_email, user_name: m.user_name, matched_by: m.matched_by }))
: [] : []
// Deduplicate: mappings may only contain mapped users, so we show what we have
const uniqueUsers = hasLoaded ? userRows : []
if (!connection) { if (!connection) {
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">

View File

@@ -108,13 +108,13 @@ export interface PsaMemberResponse {
} }
export interface PsaMemberMappingResponse { export interface PsaMemberMappingResponse {
id: string id: string | null
user_id: string user_id: string
user_email: string user_email: string
user_name: string user_name: string
external_member_id: string external_member_id: string | null
external_member_name: string external_member_name: string | null
matched_by: string matched_by: string | null
} }
export interface AutoMatchResult { export interface AutoMatchResult {