From 8eb814283d0cf4b76a2cd03f68736dbb652fb4c3 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 14 Apr 2026 06:09:01 +0000 Subject: [PATCH] fix(psa): fix time entry AttributeError and show all users in member mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/api/endpoints/integrations.py | 42 +++-- backend/app/schemas/psa_connection.py | 8 +- .../app/services/psa/connectwise/provider.py | 2 +- docs/connectwise-psa-testing-checklist.md | 158 ++++++++++++++++++ .../src/pages/account/IntegrationsPage.tsx | 13 +- frontend/src/types/integrations.ts | 8 +- 6 files changed, 197 insertions(+), 34 deletions(-) create mode 100644 docs/connectwise-psa-testing-checklist.md diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index 602de191..7b69f7fd 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -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]) diff --git a/backend/app/schemas/psa_connection.py b/backend/app/schemas/psa_connection.py index d9dfeeb4..60f881de 100644 --- a/backend/app/schemas/psa_connection.py +++ b/backend/app/schemas/psa_connection.py @@ -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): diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index 34b2ecd3..2a191c01 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -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, diff --git a/docs/connectwise-psa-testing-checklist.md b/docs/connectwise-psa-testing-checklist.md new file mode 100644 index 00000000..c9882159 --- /dev/null +++ b/docs/connectwise-psa-testing-checklist.md @@ -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 | diff --git a/frontend/src/pages/account/IntegrationsPage.tsx b/frontend/src/pages/account/IntegrationsPage.tsx index 335288b9..68967230 100644 --- a/frontend/src/pages/account/IntegrationsPage.tsx +++ b/frontend/src/pages/account/IntegrationsPage.tsx @@ -648,10 +648,12 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse | setCwMembers(members) setMappings(existingMappings) - // Build local mapping state from existing mappings + // Build local mapping state from existing mappings (skip unmapped entries) const lookup: Record = {} 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) setIsDirty(false) @@ -716,14 +718,11 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse | } } - // Derive user list from mappings response (all account users are returned) - const userRows = mappings.length > 0 + // All account users — includes both mapped and unmapped + 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 })) : [] - // Deduplicate: mappings may only contain mapped users, so we show what we have - const uniqueUsers = hasLoaded ? userRows : [] - if (!connection) { return (
diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts index fb962aed..6f507457 100644 --- a/frontend/src/types/integrations.ts +++ b/frontend/src/types/integrations.ts @@ -108,13 +108,13 @@ export interface PsaMemberResponse { } export interface PsaMemberMappingResponse { - id: string + id: string | null user_id: string user_email: string user_name: string - external_member_id: string - external_member_name: string - matched_by: string + external_member_id: string | null + external_member_name: string | null + matched_by: string | null } export interface AutoMatchResult {