119 Commits

Author SHA1 Message Date
fb7690485b fix(tickets): fix statuses endpoint, members auth gate, and graceful error handling
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
- Add GET /boards/{board_id}/statuses endpoint — direct board-to-statuses lookup
  without ticket roundabout; used by filter bar and new ticket form
- Fix TicketsPage and NewTicketModal to call getBoardStatuses(board_id) instead
  of misusing getTicketStatuses(ticket_id) with a board_id value
- Fix list_members auth: was require_account_owner (owner/super_admin only) —
  changed to require_engineer_or_admin so engineers can see member list for
  ticket assignment
- list_members: return [] on PSAError instead of 502 (Lesson 111 pattern)
- get_ticket_statuses: return [] on PSAError instead of 502

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 05:33:23 +00:00
6044d5a88b fix(tickets): fix permissions toast, board fallback, assignment search, remove load more
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
- list_resources: return [] on PSAError instead of 502 — stops global interceptor
  toast when CW API key lacks ticket members permission (Lesson 111)
- list_boards/list_priorities: add warning logging so Railway logs reveal the
  root cause when CW permissions are missing
- TicketsPage: derive board options from ticket search results when listBoards
  returns empty (CW permissions fallback)
- TicketFilterBar: replace assignment <select> with searchable member picker —
  fixed options (All/Mine/Unassigned) + text-filtered member dropdown
- TicketQueue: remove Load More / infinite scroll; page now exists at /tickets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 04:59:03 +00:00
00cd8b7c55 feat(tickets): update TicketQueue with mapping detection, 5-item cap, View All link
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:42:25 +00:00
fded959b5e fix(tickets): guard linkedTicket fetch with currentChatRef to prevent race condition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:40:31 +00:00
5f5b9e5b23 feat(tickets): add spin-off ticket creation in ResolutionAssist — state, action handler, modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:37:46 +00:00
b2ee1a2150 fix(tickets): improve accessibility and error logging in ticket creation components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:34:08 +00:00
08909aa884 feat(tickets): add AiTicketParseForm and NewTicketModal with two-tab creation flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:31:21 +00:00
070d2383bc fix: remove unused PSATicketSearchResult import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:26:23 +00:00
d7b1fe6645 feat(tickets): add TicketResourceManager and full TicketDetailPanel with optimistic hydration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:24:18 +00:00
a3f8bb3427 feat(tickets): add ticket detail subcomponents
- TicketDetailHeader: Display ticket info with status dropdown
- TicketNotesFeed: Chronological list of ticket notes with internal flag
- TicketAddNote: Form to add notes (requires linked session)
- TicketConfigs: Display related configurations/devices
- TicketRelated: List of related tickets as clickable buttons

All components use type-safe imports from psaContext and integrations APIs.
Styling follows design system (flat dark theme, electric blue accent, Tailwind v4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:19:18 +00:00
f050afc2f7 feat(tickets): add /tickets route and sidebar nav item
Add Tickets page route to router with lazy loading and code splitting.
Add Tickets navigation entry to sidebar in RESOLVE section for both
icon rail and pinned layouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:15:35 +00:00
849e1c16e2 feat(tickets): add TicketsPage with URL-param filter state, stub detail panel and modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:12:26 +00:00
5310cd3fff fix(tickets): add company_id reset to filter clear button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:09:51 +00:00
d2689afa53 feat(tickets): add TicketFilterBar and TicketListRow components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:08:15 +00:00
9d88c8456c feat(tickets): add tickets API client, update integrations API for paginated search, fix callers
- Create frontend/src/api/tickets.ts with ticketsApi (resources, status, create, ai-parse, priorities, search)
- Update integrationsApi.searchTickets and searchTicketsQueue return types from PSATicketSearchResult[] to TicketListResponse
- Fix TicketQueue.tsx to use results.items (append/set) and results.items.length for pagination check
- Fix TicketPickerModal.tsx to use results.items when setting search results
- Export ticketsApi from api/index.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:05:13 +00:00
506aac609d feat(tickets): add tickets types, expand PSATicketSearchResult/PSATicketInfo with IDs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:02:53 +00:00
7fa81f69a6 feat(psa): add spin-off ticket system prompt rule, backend routing tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:01:21 +00:00
6e0188d0b4 feat(psa): add AI ticket parse endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:59:02 +00:00
24ab1908a6 fix(psa): add TicketListResponseSchema response_model to search_tickets endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:57:23 +00:00
e2cdfac1c3 feat(psa): update search endpoint for pagination, add create/status/resource/priority endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:55:49 +00:00
a5e9615666 feat(psa): add ticket_service.py with list/add/remove resource, update_status, create_ticket
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:52:32 +00:00
66cca70588 feat(psa): expand PSATicketSearchResult with IDs, add psa_tickets.py schemas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:50:56 +00:00
e714088a2b feat(psa): implement list/add/remove resources, create_ticket, paginated search in CW provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:49:20 +00:00
ff0ec143e2 feat(psa): add PSAResource, TicketCreatePayload types and abstract provider methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:45:24 +00:00
8d964e64e4 fix(psa): update autotask/halopsa stub search_tickets return type annotation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:44:08 +00:00
44634b1145 feat(psa): add PaginatedTicketResult type, update provider search_tickets signature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:41:48 +00:00
001438008b docs: fix PSA ticket management spec — prefill state, TicketQueue naming
- Replace false claim about linkedTicket state with explicit fetch step on modal open
- Remove MyQueueWidget references; TicketQueue is the existing component being updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:00:33 +00:00
c8b68ad26d docs: fix PSA ticket management spec — pagination source, widget, linked ticket IDs
- Define PaginatedTicketResult provider type + parallel count fetch via CW /count endpoint
- Fix dashboard widget: updates existing TicketQueue (not new), uses searchTicketsQueue
- Fix NewTicketModal prefill: expand PSATicketInfo with company_id/board_id fields
- Correct Dashboard section description: not collapsible, TicketQueue already exists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:49:39 +00:00
2b3d52ad77 docs: fix PSA ticket management spec — API contract, actions format, file routing
- Explicitly call out search_tickets breaking change and all existing callers
- Fix [ACTIONS] marker to use JSON array format matching existing parser
- Route system prompt change to assistant_chat_service.py, not flowpilot_engine
- Pivot detail panel hydration to existing getTicketContext + listResources

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:44:34 +00:00
52b369680b docs: add PSA ticket management design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:36:27 +00:00
f0ccf313a4 docs: add lessons 110-111 (RLS backfill audit, axios interceptor pattern)
Some checks failed
CI / backend (push) Failing after 15m45s
CI / frontend (push) Failing after 47s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:50:43 +00:00
0d9babb986 fix(rls): add account_id to AISessionStep creations, fix boards toast
Some checks failed
CI / backend (push) Failing after 16m37s
CI / frontend (push) Failing after 45s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 3s
- flowpilot_engine: pass account_id at all 5 AISessionStep instantiation
  sites (_create_step_from_parsed x3, briefing step, status update step).
  Phase 4 RLS blocked every INSERT with NULL account_id — this broke all
  new FlowPilot sessions since the Phase 4 migration was applied.
- integrations: list_boards returns [] on PSAError instead of 502, stopping
  the spurious 'Server error' toast on dashboard load (boards are optional).
- client.ts: 5xx global toast now shows backend detail when available.
- useFlowPilotSession: startSession extracts backend detail for error state;
  suppresses duplicate toast for 5xx (global interceptor already handles it).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 04:41:14 +00:00
567985402f fix(psa): use board/id in (...) for multi-board filter per CW docs
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:54:05 +00:00
08a4c6600d fix(psa): use resources contains identifier for my tickets filter
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 3s
CW resources field is a plain string of member identifiers (login names),
not a navigable object. resources/member/id was invalid syntax causing 403.

Now resolves the CW member identifier from the cached member list and
uses: resources contains '{identifier}' which is the correct condition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:53:26 +00:00
29fa48e71b fix(psa): revert to resources/member/id for my tickets filter
Some checks failed
CI / backend (push) Has started running
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
Requires CW API member security role to have All scope on Service Tickets.
owner/id was incorrect for workflows using resources-based assignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:48:10 +00:00
908a867986 fix(psa): use owner/id instead of resources/member/id for my tickets filter
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
resources/member/id requires All scope on Service Tickets security role.
owner/id (primary assignee) works with standard Mine scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:43:34 +00:00
346576a730 feat(psa): ticket queue dashboard with board selector and session auto-start
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 2s
- Add PSABoard type + list_boards() to CW provider (cached 1h)
- Extend search_tickets with assigned_to_me, unassigned, board_ids, page, page_size
- New GET /integrations/psa/boards endpoint
- New TicketQueue dashboard component: My Tickets / Unassigned tabs,
  multi-select board filter, Load more pagination, Start Session per ticket
- Add TicketQueue to QuickStartPage after active sessions
- FlowPilotSessionPage auto-starts with ticket context when navigated
  from TicketQueue (psaTicketId + psaTicket in location.state)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:20:45 +00:00
b18072e24b fix(psa): set account_id on PsaMemberMapping in save and auto-match
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 2s
2026-04-15 02:59:49 +00:00
e0f44e2985 fix(ci): connect to postgres service by hostname, not localhost
Some checks failed
CI / backend (push) Failing after 16m41s
CI / frontend (push) Failing after 56s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 2s
2026-04-15 01:52:03 +00:00
adfbb39297 fix(ci): use --break-system-packages for pip on Ubuntu 24.04
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
CI / backend (push) Failing after 50s
CI / frontend (push) Failing after 42s
CI / e2e (push) Has been skipped
2026-04-15 01:49:58 +00:00
6bae205a8c chore: trigger CI
Some checks failed
CI / backend (push) Failing after 12s
CI / frontend (push) Failing after 1m6s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 3s
2026-04-15 01:48:17 +00:00
ee2b2c2399 feat(ci): port CI workflow from Github Actions to Gitea
Some checks failed
Mirror to GitHub / mirror (push) Successful in 3s
CI / backend (push) Failing after 35s
CI / frontend (push) Failing after 32s
CI / e2e (push) Has been skipped
2026-04-14 23:33:12 +00:00
37bc47b75b chore: add runner probe workflow
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
2026-04-14 23:27:30 +00:00
c8bdd0014e Update Github mirror workflow
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
2026-04-14 22:50:53 +00:00
2a2b770405 Update Github mirror workflow
Some checks failed
Mirror to GitHub / mirror (push) Failing after 3s
2026-04-14 22:49:20 +00:00
d6d0e9f3c1 Add GitHub mirror workflow
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s
2026-04-14 22:43:09 +00:00
ab4bf3b32f Add GitHub mirror workflow
Some checks failed
Mirror to GitHub / mirror (push) Failing after 42s
2026-04-14 22:31:37 +00:00
chihlasm
d3c93cd006 feat(admin): allow setting owner when creating an account
feat(admin): allow setting owner when creating an account
2026-04-14 17:27:02 -04:00
chihlasm
4037a5213e fix(admin): use EmailStr for owner_email validation in AdminAccountCreate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:25:03 +00:00
chihlasm
0ed5977fee feat(admin): allow setting owner when creating an account
Adds optional owner_email field to the Create Account modal. Superadmin
can specify an existing user's email to assign as account owner at
creation time. Backend 404s with a clear message if the email is unknown.
Error detail now surfaces to the toast instead of a generic message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:30:23 +00:00
chihlasm
c5b8229ef6 fix(admin): allow owner and admin account roles in user creation and role management
Four places were hardcoded to engineer|viewer only:
- AccountRoleUpdate schema (user.py) — blocked PUT /admin/users/{id}/account-role at the API level
- AdminUserCreate schema (admin.py) — blocked creating users with owner/admin role
- AccountDetailPage role dropdowns (create form + inline member role changer)
- AccountsPage create user role dropdown

Now all four accept the full set: owner, admin, engineer, viewer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:24:17 +00:00
chihlasm
eba50e1f95 docs(claude-md): trim GitNexus section to selective-use guidance
Remove mandatory "MUST run before every edit" rules — they add overhead
without value for additive/isolated changes. Keep the tools table and
use-it-when-it-matters guidance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:58:40 +00:00
chihlasm
8eb814283d 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>
2026-04-14 06:09:01 +00:00
chihlasm
b433b232dc polish(network): visual refinements across node, edge, and panel components
- DeviceNode: flat bg-card (no surface gradient), darker icon plate inset,
  correct text-muted token for category label
- GroupNode: label pill gets bg-card/90 background so it reads against canvas
- ConnectionEdge: label now has border + bg-card so it doesn't float invisible
- BaseHandle: tightened to 12px with accent-toned border
- NodeStatusIndicator: glow reduced to 0.15 opacity (design system compliant)
- ContextMenu: Ungroup now uses Ungroup icon instead of BoxSelect
- DeviceToolbar: group type icons coloured with semantic palette
- PropertiesPanel: empty state gets icon tile + cleaner copy hierarchy
- DiagramEditor: shortcut ? button repositioned above MiniMap, accent hover
- NetworkDiagrams list: card thumbnail placeholder uses dot-grid pattern,
  card menu gets icons and divider before destructive action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 05:35:25 +00:00
chihlasm
015df1fe5f fix(network): consolidate import buttons, redesign empty state, add shortcut overlay
- Import/Export button in editor header: removed standalone Import button, moved
  draw.io import into Export/Import dropdown with labelled sections; fixes
  conceptual trap where Import implied operating on the current diagram
- List page: replaced two identical Upload-icon Import buttons with a single
  dropdown (Import JSON / Import draw.io) with format descriptions
- Empty state: replaced icon-in-box with a horizontal card featuring a static
  SVG topology preview, MSP-specific value prop, and dual CTAs
- Keyboard shortcuts: new KeyboardShortcutsOverlay component (4-group grid),
  triggered by ? key or the ? button pinned to the canvas bottom-right corner;
  wired into useCanvasShortcuts hook
- Fixed Share2 → FileOutput icon for draw.io export (Share2 = send to someone,
  FileOutput = export file format)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 04:49:25 +00:00
chihlasm
cf9c258f9e fix(network): surface connect tool and middle-pan 2026-04-14 03:41:21 +00:00
chihlasm
c063952f12 feat(network): add connect tool and middle-pan 2026-04-14 03:28:07 +00:00
chihlasm
36721eb5af feat(network): improve connector editing 2026-04-14 02:56:28 +00:00
chihlasm
3cd4084f78 refactor(network): simplify diagram node visuals 2026-04-14 02:42:47 +00:00
chihlasm
ed763d1cea chore(network): remove asset style lab 2026-04-14 02:29:26 +00:00
chihlasm
c37e216e0b feat(network): add asset style lab mockups 2026-04-14 02:10:48 +00:00
chihlasm
91cc9a4170 feat(network): draw.io XML import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:30:22 +00:00
chihlasm
2a4220b496 feat(network): draw.io XML export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:25:49 +00:00
chihlasm
c8f571db39 feat(network): thumbnail generation on save, shown on list page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:22:51 +00:00
chihlasm
7efa22454d feat(network): improve PDF export with print stylesheet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:20:28 +00:00
chihlasm
05421fc65c feat(network): add SVG export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:19:19 +00:00
chihlasm
dfcad531e2 fix(network): context menu on groups + group/ungroup in properties panel
Context menu fix:
- Group nodes pass pointer events through to children in React Flow, so
  right-clicking a group fires onPaneContextMenu instead of onNodeContextMenu
- handlePaneContextMenu now checks for selected nodes and shows the node
  context menu (with align/group options) when any nodes are selected

Properties panel multi-select:
- Add Group section with type dropdown (Subnet, VLAN, Site, DMZ, Custom)
- "Group into [Type]" button creates a group of the chosen type
- Ungroup button appears when a group node is in the selection
- useDiagramCommands.groupSelection now accepts a groupType param and
  uses it as the label and color key for the new group node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:55:34 +00:00
chihlasm
684fb07e47 feat(network): add pointer/hand mode toggle to diagram toolbar
- Header shows MousePointer2 (select) and Hand (pan) toggle buttons
- Select mode: drag on canvas draws a selection box (selectionOnDrag)
- Pan mode: drag on canvas pans the viewport (panOnDrag)
- Space held in either mode temporarily switches to pan (panActivationKeyCode)
- Keyboard shortcuts: V = select mode, H = pan mode
- Cursor changes to grab/grabbing in pan mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:38:51 +00:00
chihlasm
4a12c9b37d fix(network): persist group node type, size, and child parentId on save/load
Backend DiagramNode schema was missing nodeType, style, and parentId fields —
Pydantic stripped them on save, so group nodes lost their identity on reload
and re-appeared as small device icons.

- Backend: add nodeType, style (NodeStyle), parentId to DiagramNode schema
- Frontend: serialize parentId for device nodes inside groups
- Frontend: restore parentId + extent:'parent' on both deserializer paths (setNodes + history init)
- Frontend: add parentId to DiagramNode interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:49:26 +00:00
chihlasm
e41d7bd960 fix(network): align resize border with node visual boundary
NodeResizer handles positioned at RF wrapper size, but NodeTooltip and
NodeStatusIndicator wrappers had no size constraints, causing BaseNode
(w-full h-full) to shrink to content size instead of filling the wrapper.

Add w-full h-full to NodeTooltip, NodeTooltipTrigger, and
NodeStatusIndicator so the full height chain is maintained.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:27:58 +00:00
chihlasm
f2c3bd7a9b fix(network): normalize z-order to 1..N after bring-to-front/send-to-back
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:17:44 +00:00
chihlasm
9786c6b1fb feat(network): add inline label editing on DeviceNode (double-click)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:17:41 +00:00
chihlasm
4529955f7d feat(network): add orthogonal edge routing option
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:17:33 +00:00
chihlasm
b7b0d41f92 feat(network): add group/ungroup commands with bounding box calculation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:14:26 +00:00
chihlasm
a4512dcf90 feat(network): add GroupNode component with resize, inline label, and group type colors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:13:03 +00:00
chihlasm
764db79060 feat(network): add alignment toolbar to PropertiesPanel for multi-select
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:11:12 +00:00
chihlasm
f90e2c956f feat(network): add align/distribute/group sections to context menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:09:32 +00:00
chihlasm
bdaea68dd3 feat(network): add useDiagramCommands — alignment and distribution command layer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:08:37 +00:00
chihlasm
02c19a7580 feat(network): add undo/redo shortcuts (Ctrl+Z/Y) and arrow key nudging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:06:33 +00:00
chihlasm
a392d24101 feat(network): add undo/redo buttons to DiagramHeader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:04:58 +00:00
chihlasm
b9c9bb548d fix(network): force re-render on undo/redo so canUndo/canRedo stay accurate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:03:35 +00:00
chihlasm
662df2907d feat(network): add undo/redo snapshot history stack to DiagramEditor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:01:21 +00:00
chihlasm
b9547e6ce1 docs: add network diagrams Phase 2 implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:23:23 +00:00
chihlasm
760e0f77f8 docs: add network diagram draw.io-style implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:16:54 +00:00
chihlasm
a71f082e25 feat: extract admin account management rework from PR 124 (#138)
* feat: reorganize admin panel around accounts

* feat: expand admin customer account controls

* feat: add admin account detail management

* fix: remove unused admin account icon import

* refactor: design critique fixes for account pages

- Admin accounts: replace dense card grid with compact DataTable
- Account settings: remove redundant hero card, stat grid, header pills
- Fix bg-accent (orange) misuse on decorative elements across 7 files
- Add ConfirmButton for destructive actions (deactivate, remove member)
- Replace single-field modals with inline editing (plan, trial)
- Add contextual help: display code tooltip, improved empty states
- Non-owner aside explanation for hidden owner-only sections
- Admin sidebar: group 11 items into 5 labeled sections
- Rename UsersPage.tsx → AccountsPage.tsx to match route
- Fix border radius consistency, hide zero-count badges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use get_admin_db for all new admin account endpoints

All admin endpoints query across tenants without a tenant context.
get_db (app-role, subject to RLS) was never imported and would crash
at runtime — replace all 6 occurrences with get_admin_db (BYPASSRLS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:44:51 -04:00
chihlasm
abd79bc763 feat: extract network map builder from PR 124 (#137)
* feat: add device_types table with system seed data

Creates DeviceType SQLAlchemy model and migration 073 that provisions the
device_types table with 28 system-seeded device types across 7 categories
(network, compute, storage, cloud, endpoint, infrastructure, security).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add network_diagrams table

Create NetworkDiagram SQLAlchemy model with JSONB nodes/edges, team-scoped with client/asset metadata, and Alembic migration 074.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Pydantic schemas for device types and network diagrams

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add device types CRUD router

Adds GET/POST/PUT/DELETE endpoints at /device-types with team-scoped access. System types are read-only; custom types are scoped to the creating team.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add AI generation service for network diagrams

Adds network_diagram_ai_service.py with generate_diagram() function that
calls the AI provider to convert plain-English network descriptions into
structured DiagramNode/DiagramEdge data. Registers the action in
ACTION_MODEL_MAP as a standard-tier route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add network diagrams CRUD + AI generate + export/import router

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add TypeScript types for network diagrams

Adds all interfaces for network diagrams and device types including
DiagramNode, DiagramEdge, DeviceProperties, NetworkDiagramResponse,
AI generate request/response, import/export shapes, and list item types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add frontend API clients for device types and network diagrams

Adds deviceTypesApi (list, create, update, remove) and networkDiagramsApi
(list, get, create, update, archive, duplicate, exportJson, importJson,
aiGenerate, listClients) following the existing apiClient module pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add device registry, DeviceNode, ConnectionEdge for React Flow

Creates the React Flow building blocks for the network diagram editor:
device type registry with icon/color mappings, DeviceNode component with
status indicators and connection handles, ConnectionEdge with per-type
styling, and nodeTypes/edgeTypes registration maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add PropertiesPanel for node and edge property editing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add AIAssistPanel with replace and merge modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add NetworkCanvas wrapper and DiagramHeader components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add DiagramEditor page assembling all panels with auto-save and AI generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Network Diagrams list page with search, client filter, import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Network Maps to sidebar navigation and router

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve TypeScript errors in DeviceToolbar and DiagramEditor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve stale selection bug in network diagram PropertiesPanel

Selection state now stores IDs and derives objects from live arrays,
so edits in PropertiesPanel inputs reflect immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add React Flow UI foundation components for network diagrams

BaseNode (structured node shell with header/content/footer slots),
BaseHandle (styled connection handle), LabeledHandle (handle with
port label), NodeStatusIndicator (status border effect),
NodeTooltip (hover details via NodeToolbar).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add LabeledGroupNode and AnimatedSvgEdge components

GroupNode for subnet/VLAN/site grouping with positioned label badge.
AnimatedSvgEdge for traffic flow visualization with animated SVG
shape along edge path. Both registered in type maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: DeviceNode uses BaseNode, BaseHandle, StatusIndicator, Tooltip

Replaces hand-rolled node layout with composable React Flow UI
components. Status is now a border effect instead of a dot.
Hover tooltip shows hostname, IP, vendor, role, notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add grouping toolbar items and traffic flow toggle

DeviceToolbar gets Subnet/VLAN/Site/DMZ grouping section with
drag-drop. PropertiesPanel gets Show Traffic toggle that switches
edges between connection and animated types. DiagramEditor handles
both device and group node drops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address code review findings for React Flow UI integration

- Use screenToFlowPosition() for drop coordinates (fixes zoom/pan bug)
- Remove duplicate selection border from DeviceNode (BaseNode handles it)
- Add w-full to GroupNode for proper container sizing
- Remove unused 'selected' destructuring from DeviceNode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add ISP icon to network diagram device registry

Globe icon with accent color, under cloud category.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: improve drag-and-drop feel in network diagram editor

Grip icons on draggable toolbar items, press effect on drag start,
dashed border overlay with 'Drop to add' text when dragging over canvas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add ContextMenu component for network diagram editor

Charcoal-styled context menu with action factories for node
and canvas variants. Viewport-clamped positioning, auto-dismiss
on click outside, escape, or scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add useCanvasShortcuts hook for copy/paste/duplicate

Keyboard shortcuts with preventDefault and input guard.
Clipboard stores nodes with relative positions and edge indices.
Paste computes canvas center via screenToFlowPosition.
Duplicate offsets +30px. Supports both device and group nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: wire context menu and keyboard shortcuts into diagram editor

Right-click context menus for nodes (copy/duplicate/delete) and
canvas (paste/select-all/fit-view). Right-click selects the node
per spec. serializeNodes now handles group nodes correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: context menu dismisses on pane click, ISP in toolbar

Context menu now closes when clicking anywhere on the canvas via
onPaneClick prop. ISP device added as built-in toolbar item under
Internet section so it's always available without a database entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: backend code review fixes for network diagrams

- Replace legacy Optional imports with modern str | None syntax
- Type JSONB columns as Mapped[list[dict[str, Any]]]
- Escape SQL LIKE wildcards (%, _) in diagram search
- Type DiagramNode.position as Position(x, y) Pydantic model
- Wrap AI response parsing in KeyError handler for clean 422 errors
- Remove unused Optional/TYPE_CHECKING imports from schemas/models
- Extract _get_available_slugs helper to DRY duplicate queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: network diagram editor UX — straight edges, snap-to-grid, ISP in Cloud, group resize

- Straight edges: replace SmoothStepEdge with BaseEdge + getStraightPath so
  connections draw direct diagonal lines instead of orthogonal bent paths
- Snap-to-grid: add snapToGrid/snapGrid=[20,20] to NetworkCanvas so nodes
  align consistently when dragged
- ISP in Cloud: remove standalone "Internet" sidebar section, inject ISP into
  the Cloud category loop with search support and correct item count
- Group node resize: add NodeResizer to GroupNode (subnet/VLAN/site/DMZ),
  handles visible when selected; dimensions saved/restored correctly on
  reload (also fixes group node load bug where type was always 'device')
- DiagramNode type: add nodeType and style optional fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: network diagram team_id guard + multi-style edge routing

Backend:
- Guard create_diagram with 422 if current_user.team_id is None (prevents
  NOT NULL constraint crash for accounts not yet assigned to a team)
- Add routing field to DiagramEdge schema (straight/curved/step)

Frontend:
- ConnectionEdge now supports straight (default), curved (bezier), and
  step (smooth-step) routing per-edge via routing field in edge data
- PropertiesPanel Connection section gets a Line Style toggle:
  Straight | Curved | Step buttons, active state highlights in accent
- handleEdgeUpdate and serializeEdges now propagate the routing field
- DiagramEdge type gets optional routing field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: network diagrams UX overhaul — icons, empty canvas, properties panel

- Colorize: semantic category colors for all device types (network=blue,
  security=orange, compute=emerald, endpoint=amber, storage=violet,
  cloud=cyan, infra=steel); better icons (Router, ShieldAlert, Boxes,
  Package, Gauge, PlugZap, Video, Radio); MiniMap uses category colors
- Onboard: centered AI generate prompt on empty canvas with 5 MSP-specific
  example chips, ⌘↵ shortcut, spinner; AIAssistPanel only shown with nodes
- Arrange: properties panel — status badge grid at top, fields grouped into
  Network (IP/Subnet/VLAN) and Hardware (Hostname/Vendor/Model/Role) sections
- Delight: segmented topology color bar on listing cards; backend returns
  category_counts via single extra query on list endpoint
- Harden: real PNG export via html-to-image + getNodesBounds/getViewportForBounds
- Polish: ChevronDown replaces unicode ▾, click-outside for client filter,
  consistent spinner in empty prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: drop changelog noise from network extraction

* fix: align network map builder with account isolation

* feat: add manual create option for network maps

* feat: make manual network map creation easier to discover

* fix(network-maps): address design critique — harden, normalize, clarify, polish

- Archive: two-step inline confirm in card dropdown menu
- Delete Device/Edge: two-step inline confirm in PropertiesPanel footer
- Context menu Delete: floating confirm bar instead of immediate deletion
- AI Generate New: two-step confirm when replacing existing diagram nodes
- DiagramHeader: show 'Unsaved changes' in amber when isDirty and not saving
- deviceRegistry: SECURITY_COLOR #f97316 → #f87171 (deprecated ember orange removed)
- CanvasEmptyPrompt: remove backdrop-blur (design system violation)
- CanvasEmptyPrompt: remove redundant 'Skip AI' bottom button (duplicate of Build manually card)
- CanvasEmptyPrompt: rounded-xl/rounded-2xl → rounded-lg, border-2 → border
- Topology bar: h-1 → h-2 + native tooltip with category breakdown
- AIAssistPanel: replace pulse-dot loading with spinner (consistent with rest of feature)
- ContextMenu: add shadow-lg (consistent with other dropdowns)
- DeviceNode tooltip: Position.Bottom → Position.Top (avoids canvas-edge clipping)
- CanvasEmptyPrompt: raise ⌘↵ hint from /50 opacity to full text-muted-foreground

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(network-maps): bring to front / send to back layering for nodes

Three entry points for z-index control:
- Right-click context menu: Bring to Front / Send to Back with ] / [ shortcuts, separated by dividers from copy/delete groups
- Properties panel: Layer row with Bring Front + Send Back buttons, tooltip shows keyboard shortcut
- Keyboard: ] brings selected node(s) to front, [ sends to back (skips when input focused)

Context menu also gains divider support (dividerBefore flag) for visual grouping.
Layering handlers use max/min zIndex across all nodes so repeated presses always stack correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: swap switch icon from Layers → Network (Lucide)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: icon size picker (S/M/L) on device nodes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: drag-to-resize device nodes + BrickWallFire for firewall

- NodeResizer on DeviceNode (same pattern as group nodes); icon scales
  proportionally with node width, clamped 16–60px
- Removes S/M/L static picker — resize is now direct manipulation
- firewall: ShieldAlert → BrickWallFire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: trigger Railway rebuild

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add missing hero_001.jpg to git (was untracked, broke Railway deploy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: ShieldAlert still referenced in CATEGORY_DEFAULTS after icon swap

Removed ShieldAlert from imports when swapping firewall icon to BrickWallFire
but left it in CATEGORY_DEFAULTS — runtime crash, device toolbar empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(network): proportional node resize with locked aspect ratio

Nodes grew into rectangles because NodeResizer had no aspect ratio
constraint, minWidth != minHeight, and icon/text only scaled from width.

- DeviceNode: add keepAspectRatio + equal minWidth/minHeight (80×80),
  maxWidth/maxHeight (280×280), scale icon and label/IP font sizes from
  Math.min(width, height) so all content grows uniformly
- DiagramEditor: set explicit 120×120 style on dropped device nodes so
  React Flow has a definite starting size for aspect ratio calculation
- DiagramEditor: persist device node style (width/height) in
  serializeNodes and restore it on load so size survives save/reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): suppress ESLint errors in network diagram components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:38:01 -04:00
Claude
af5ceea7f9 docs: update CHANGELOG with Phase 4 tenant isolation details
- Added Phase 4 RLS enforcement on all 31 remaining tables (#136)
- Documented BYPASSRLS session pattern and admin session factory
- Listed Phase 4 fixes for auth deps, background jobs, and seed scripts

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-12 10:44:07 +00:00
chihlasm
f54d7ecd78 docs: update current state after Phase 4 merge 2026-04-12 04:35:30 +00:00
chihlasm
46593ba8ca Merge PR #136: feat: tenant isolation Phase 4 — RLS on all remaining tables 2026-04-12 04:35:01 +00:00
chihlasm
52553d62d2 fix(tests): update expectations for RLS-correct behavior
- test_rls_isolation: add pytestmark for module-scoped event loop to fix
  "Future attached to a different loop" with pytest-asyncio 0.23 + asyncpg
  module-scoped fixtures
- test_admin_categories_global: global categories use PLATFORM_ACCOUNT_ID
  not NULL; update stale assertion
- test_permissions_account: with RLS, cross-tenant tree access returns 404
  (invisible) not 403 (forbidden) — update to match actual behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:48:30 +00:00
chihlasm
a48660700a fix: background jobs and lifespan must use BYPASSRLS sessions
All code that runs outside a request context (APScheduler jobs,
lifespan startup) has no app.current_account_id set, so the
app-role session returns 0 rows from every RLS-protected table.

Changed to _admin_session_factory (BYPASSRLS) in:
- knowledge_flywheel_scheduler.py — queries ai_sessions
- psa_retry_scheduler.py — queries psa_post_log
- retention_cleanup.py — queries assistant_chats
- scheduler.py (_fire_maintenance_schedule, _cleanup_expired_ai_conversations)
- main.py (archive_stale_ai_sessions, _process_notification_retries,
  load_all_schedules at startup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:44:23 +00:00
chihlasm
3ff886363c fix: use BYPASSRLS session for all auth deps and user-mutation endpoints
Phase 4 enabled RLS on the users table. All code paths that touch users
(or other RLS-protected tables) before require_tenant_context sets
app.current_account_id must use get_admin_db (BYPASSRLS):

- deps.py: get_current_user and get_current_active_user → get_admin_db
- auth.py: all endpoints → get_admin_db (login, register, refresh, etc.
  run before tenant context exists; mutation endpoints also need session
  consistency since current_user is in the admin session)
- accounts.py: transfer_ownership, leave_account, delete_account
  → get_admin_db (these mutate current_user directly)
- onboarding.py: dismiss_onboarding → get_admin_db (same reason)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:25:18 +00:00
chihlasm
501442e5f0 fix: seed_test_users must use ADMIN_DATABASE_URL after Phase 4 RLS on users
RLS is now enabled on the users table. The seed script was using the
app-role connection (DATABASE_URL) which has no tenant context at seed
time — all SELECTs return 0 rows and INSERTs are blocked by FORCE RLS.

Falls back to DATABASE_URL if ADMIN_DATABASE_URL is not set (local dev
without roles configured).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 03:12:46 +00:00
chihlasm
6f53ec06f5 docs: add lessons 107-109 — RLS startup, global tables, tree_shares account_id
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 02:58:12 +00:00
chihlasm
ec322f7cdf fix: bootstrap service account with BYPASSRLS session 2026-04-12 02:44:36 +00:00
chihlasm
f9248aeaa8 fix: remove platform_steps and template_trees from Phase 4 RLS
Both tables have no account_id column — they are globally readable
by all authenticated users and must not have RLS policies.

Also removes the corresponding test cases that assumed these tables
had account_id-based policies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:48:50 +00:00
chihlasm
c6da4ebee5 fix: remove script_categories from Phase 4 RLS — no account_id column
script_categories is a global lookup table (shared across all tenants).
The account_id column belongs to ScriptTemplate in the same model file,
not ScriptCategory. The Python scan matched the file, not the class.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:32:42 +00:00
chihlasm
64f004a62c feat: tenant isolation Phase 4 — RLS on 31 remaining tables + script_builder fix
Enable RLS on all remaining tenant-scoped tables (31 tables):

Standard policy (tenant sees own rows):
  users, account_invites, account_limit_overrides, account_feature_overrides,
  subscriptions, ai_chat_sessions, ai_conversations, ai_session_steps,
  ai_session_embeddings, ai_suggestions, ai_usage, assistant_chats,
  attachments, copilot_conversations, feedback, file_uploads, fork_points,
  kb_imports, notifications, notification_configs, notification_logs,
  psa_activity_logs, psa_member_mappings, script_builder_sessions,
  script_categories, session_ratings, tree_embeddings, user_folders,
  user_pinned_trees

Platform-visibility policy (own rows OR PLATFORM_ACCOUNT_ID):
  platform_steps, template_trees

Intentionally skipped:
  accounts (IS the root table, no account_id column)
  plan_feature_defaults (platform config, no account_id column)

Also fixes script_builder_service.create_session() which was missing
account_id= on ScriptBuilderSession construction, causing 500s on all
script builder endpoints (pre-existing CI failure).

Adds Phase 4 RLS isolation tests covering: users, script_builder_sessions,
ai_session_steps, notifications, platform_steps, template_trees.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:25:28 +00:00
Claude
ba36e37dab docs: update CHANGELOG with Tenant Isolation Phase 2 and Phase 3 details
- Document Phase 2: PostgreSQL RLS on 11 session tables, account_id NOT NULL enforcement, Alembic migration support
- Document Phase 3: RLS on audit_logs and tree_shares, cross-tenant session access for public shares, complete account_id propagation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:43:10 +00:00
chihlasm
9e6965512b Merge pull request #135 from resolutionflow/feat/tenant-isolation-phase-3
feat: tenant isolation Phase 3 — audit_logs, tree_shares, remaining RLS
2026-04-11 04:28:47 -04:00
chihlasm
893b8a5008 fix: tree_shares.account_id must come from tree owner, not the actor
- trees.py: change account_id=current_user.account_id →
  account_id=tree.account_id so super-admin cross-account shares land in
  the tree's tenant where RLS will see them.

- migration a05e1a1bea7c: fix backfill to join tree_shares → trees instead
  of tree_shares → users(created_by). Same logic: historical shares belong
  to the tree's tenant.

- test_tree_sharing.py: add test_share_account_id_matches_tree_not_actor
  to assert share.account_id == tree.account_id after POST /share; also
  add missing account_id to all direct TreeShare(...) constructors in
  existing tests.

- test_phase1_migrations.py: remove team_id= from TargetList constructor
  (column dropped in Phase 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 07:02:35 +00:00
chihlasm
e05472615b feat: tenant isolation Phase 3 — audit_logs, tree_shares, remaining RLS
P3-A: Add account_id to audit_logs model + migration (backfill via user_id →
  users.account_id). log_audit() gains optional account_id param with fallback
  SELECT to avoid churn across 40 call sites.

P3-B: Add account_id to tree_shares model + migration (backfill via created_by
  → users.account_id). TreeShare constructor updated in trees.py.

P3-C: Enable RLS on 6 remaining tables: step_ratings, step_usage_log,
  target_lists, session_shares, audit_logs, tree_shares.

P3-D: Drop team_id from target_lists — endpoint, schema, and model now use
  account_id as the sole isolation key.

P3-E: Append Phase 3 RLS isolation tests for all 6 tables.

test_target_lists.py: fix cross-account test to use Account model (not Team)
and set account_id on new User.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 07:02:35 +00:00
chihlasm
00fdd663bc Merge pull request #134 from resolutionflow/feat/tenant-isolation-phase-2
feat: Phase 2 tenant isolation — RLS on 11 session tables
2026-04-11 03:02:25 -04:00
chihlasm
8cf58add22 fix: use valid confidence_tier value in RLS test ai_sessions INSERT
'medium' is not a valid value for ck_ai_sessions_confidence_tier.
Valid values are 'guided' | 'exploring' | 'discovery'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 05:28:52 +00:00
chihlasm
6c231ef1c6 fix: use started_at (not created_at) in RLS test session INSERT
sessions table has started_at as the timestamp column, not created_at.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 04:53:35 +00:00
chihlasm
758cd61621 fix: propagate account_id through all write paths missing NOT NULL coverage
Service layer (production code):
- branch_manager: set account_id on SessionBranch (root + fork) and ForkPoint
  from session.account_id; load session in create_fork for this purpose
- handoff_manager: set account_id on SessionHandoff from session.account_id
- ai_suggestions endpoint: set account_id on AISuggestion from current_user
- steps endpoint (/feedback): set account_id on StepRating from current_user
- ratings endpoint: set account_id on StepRating from current_user

Test infrastructure:
- conftest.py: seed PLATFORM_ACCOUNT_ID (00000000-...-0001) account after
  Base.metadata.create_all so global categories and gallery items have a valid FK
- test_rls_isolation: add _ensure_rls_schema fixture that runs
  'alembic upgrade head' before module tests — previous function-scoped
  test_db fixtures drop the schema, leaving the RLS tests with no tables
- test_branding: create Account before User in helper functions
- test_admin_gallery: set account_id=PLATFORM_ACCOUNT_ID on Tree/ScriptTemplate
- test_public_templates: set account_id=PLATFORM_ACCOUNT_ID on Tree,
  ScriptTemplate, TreeCategory
- test_resolution_outputs: set account_id=session.account_id on
  SessionResolutionOutput
- test_analytics_phase5: set account_id on PsaPostLog
- test_draft_trees: replace account_id=None with PLATFORM_ACCOUNT_ID in
  migration default test (NOT NULL now enforced)
- test_maintenance_schedules: set account_id on other_tree
- test_save_session_as_tree: set account_id on all 5 Session() constructors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 04:24:36 +00:00
chihlasm
b9fcdd5d73 fix: use DATABASE_URL_SYNC (Railway reference var) as primary Alembic URL
DATABASE_URL_SYNC is now set as a Railway reference variable pointing to
${{pgvector.DATABASE_URL}}, which resolves to the correct postgres superuser
credentials per environment (production, PR preview, fresh DBs). This handles
the bootstrap case where resolutionflow_admin doesn't exist yet.

Falls back to ADMIN_DATABASE_URL (sync-converted) for local dev only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:42:07 +00:00
chihlasm
4273ed0e5c fix: use Railway native PG env vars for Alembic migrations
Prior approach (ADMIN_DATABASE_URL first) broke PR preview environments: fresh
Railway PostgreSQL instances have no resolutionflow_admin role yet, so the admin
URL fails before the create_db_roles migration can run (bootstrap deadlock).

New priority order in _alembic_sync_url():
1. PGHOST/PGUSER/PGPASSWORD/PGDATABASE — Railway auto-links these from the
   PostgreSQL service per-environment, giving correct superuser creds for every
   env including fresh PR preview DBs where no custom roles exist yet.
2. ADMIN_DATABASE_URL (resolutionflow_admin, BYPASSRLS, asyncpg→sync) — local
   dev and stable envs where the role already exists.
3. DATABASE_URL_SYNC — legacy fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:35:04 +00:00
chihlasm
0107d2d896 fix: use resolutionflow_admin for Alembic migrations (avoid postgres superuser)
DATABASE_URL_SYNC uses the postgres superuser whose password is unavailable
in Railway after Phase 1 switched runtime to the app role. resolutionflow_admin
(BYPASSRLS) is the correct role for migrations. Derive a psycopg2 sync URL from
ADMIN_DATABASE_URL; fall back to DATABASE_URL_SYNC for local dev environments
where ADMIN_DATABASE_URL is not set separately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:23:32 +00:00
chihlasm
79ae34108a fix: add Alembic migrations step + RLS env vars to CI
- Run alembic upgrade head before tests so DB roles and RLS policies exist
- Set TEST_DB_NAME=resolutionflow_test so test_rls_isolation.py connects to
  the correct database (was defaulting to patherly_test which doesn't exist in CI)
- Set DB_APP_ROLE_PASSWORD so create_db_roles migration creates resolutionflow_app
  with a known password that the RLS isolation tests can connect with

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:55:10 +00:00
chihlasm
bd29f590a2 fix: set account_id on all Session constructors; fix 3 ESLint errors in CI
Backend: start_session, prepare_session, batch_launch_sessions all missing
account_id=current_user.account_id — Phase 1 NOT NULL constraint made these
500 in test suite (test_ratings.py fixture couldn't create sessions).

Frontend ESLint:
- TaskLane.tsx: suppress react-refresh/only-export-components for clearTaskState
- TeamSummary.tsx: init loading from isAccountOwner to avoid sync setState in effect
- ScriptBodyEditor.tsx: move lastValueRef.current assignment into useEffect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:41:42 +00:00
chihlasm
ce4cfc3240 fix: set account_id on PsaPostLog in psa_post_to_ticket (missed third write path); fix get_admin_db docstring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:12:45 +00:00
chihlasm
82ee177d9b fix: harden Phase 2 RLS tests — try/finally cleanup, assert guards, seed B-data for isolation checks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:07:26 +00:00
chihlasm
ed8de92c52 test: add Phase 2 RLS isolation tests for 11 session tables (incl. step_library visibility regression)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:00:09 +00:00
chihlasm
5bd331ca92 fix: clarify step_library RLS comment; remove unused sqlalchemy import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:57:41 +00:00
chihlasm
87fac02e9b feat: migration — enable RLS on 11 Phase 2 session tables (tenant-only + step_library visibility policy)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:55:25 +00:00
chihlasm
4f4bc435da docs: broaden admin_database docstring to cover non-admin BYPASSRLS use cases
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:51:53 +00:00
chihlasm
ac2b193909 fix: use get_admin_db in access_share to handle cross-tenant session reads (public shares)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:50:00 +00:00
chihlasm
b641ac6c55 fix: set account_id on session_supporting_data, session_resolution_outputs, maintenance_schedules, psa_post_log constructors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 06:44:17 +00:00
175 changed files with 17285 additions and 1530 deletions

154
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,154 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: resolutionflow_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
SECRET_KEY: ci-test-secret-key-not-for-production
DEBUG: "true"
APP_NAME: ResolutionFlow
TEST_DB_NAME: resolutionflow_test
DB_APP_ROLE_PASSWORD: app_secret_ci
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run Alembic migrations
run: cd backend && alembic upgrade head
- name: Check tenant filter enforcement
run: cd backend && python scripts/check_tenant_filters.py
- name: Run tests with coverage
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
- name: Display coverage summary
if: always()
run: |
cd backend
python -c "
import json
with open('coverage.json') as f:
data = json.load(f)
total = data['totals']['percent_covered_display']
print(f'Total coverage: {total}%')
print()
print('Module coverage:')
for fname, fdata in sorted(data['files'].items()):
pct = fdata['summary']['percent_covered_display']
if float(pct) < 80:
print(f' WARNING {fname}: {pct}%')
else:
print(f' OK {fname}: {pct}%')
"
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: cd frontend && npm ci
- name: Lint
run: cd frontend && npm run lint
- name: Test with coverage
run: cd frontend && npm run test:coverage
- name: Build
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: frontend/dist
retention-days: 1
e2e:
needs: [frontend]
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: resolutionflow_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
PLAYWRIGHT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
PLAYWRIGHT_DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
PLAYWRIGHT_API_ORIGIN: http://127.0.0.1:8000
PLAYWRIGHT_BASE_URL: http://127.0.0.1:4173
PLAYWRIGHT_SECRET_KEY: ci-playwright-secret-key
PLAYWRIGHT_TEST_EMAIL: teamadmin@resolutionflow.example.com
PLAYWRIGHT_TEST_PASSWORD: TestPass123!
steps:
- uses: actions/checkout@v4
- name: Install backend dependencies
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Download frontend build
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: frontend/dist
- name: Install Playwright browser
run: cd frontend && npx playwright install --with-deps chromium
- name: Run Playwright smoke tests
run: cd frontend && npm run test:e2e
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
frontend/playwright-report
frontend/test-results
if-no-files-found: ignore

View File

@@ -0,0 +1,19 @@
name: Mirror to GitHub
on:
push:
branches:
- '**'
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Push to GitHub
run: |
cd /tmp
git clone --mirror https://gitea.resolutionflow.com/chihlasm/resolutionflow.git repo
cd repo
git remote add github https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${{ secrets.GH_MIRROR_REPO }}
git push github --all --force
git push github --tags --force

View File

@@ -0,0 +1,43 @@
name: Runner Probe
on:
workflow_dispatch:
jobs:
probe:
runs-on: ubuntu-latest
steps:
- name: Runner labels and OS
run: |
echo "=== OS ==="
uname -a
cat /etc/os-release 2>/dev/null || true
- name: Python versions
run: |
echo "=== Python ==="
which python3 && python3 --version || echo "python3 not found"
which python && python --version || echo "python not found"
ls /usr/bin/python* 2>/dev/null || true
- name: Node versions
run: |
echo "=== Node ==="
which node && node --version || echo "node not found"
which npm && npm --version || echo "npm not found"
ls /usr/bin/node* 2>/dev/null || true
ls ~/.nvm/versions/node/ 2>/dev/null || echo "no nvm versions"
- name: Docker
run: |
echo "=== Docker ==="
which docker && docker --version || echo "docker not found"
docker info 2>/dev/null | grep -E "Server Version|Operating System" || true
- name: User and home
run: |
echo "=== User ==="
whoami
echo "HOME=$HOME"
echo "PATH=$PATH"

View File

@@ -31,6 +31,8 @@ jobs:
SECRET_KEY: ci-test-secret-key-not-for-production SECRET_KEY: ci-test-secret-key-not-for-production
DEBUG: "true" DEBUG: "true"
APP_NAME: ResolutionFlow APP_NAME: ResolutionFlow
TEST_DB_NAME: resolutionflow_test
DB_APP_ROLE_PASSWORD: app_secret_ci
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
@@ -47,6 +49,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run Alembic migrations
run: cd backend && alembic upgrade head
- name: Check tenant filter enforcement - name: Check tenant filter enforcement
run: cd backend && python scripts/check_tenant_filters.py run: cd backend && python scripts/check_tenant_filters.py
# Warn mode only (exits 0). Switch to --fail after Phase 1 backlog clears. # Warn mode only (exits 0). Switch to --fail after Phase 1 backlog clears.

View File

@@ -9,7 +9,9 @@ All notable changes to ResolutionFlow are documented here.
- Recurring Issue Detection — client-specific pattern alerts (#60) - Recurring Issue Detection — client-specific pattern alerts (#60)
- Step Feedback Flag — "This Step is Wrong" reporting (#58) - Step Feedback Flag — "This Step is Wrong" reporting (#58)
- **Tenant Isolation Phase 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters - **Tenant Isolation Phase 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters
- **Tenant Isolation Phase 1** — PostgreSQL Row-Level Security (RLS) enforcement across all core tables (trees, tags, categories, psa_connections, flow_proposals) with database role separation (`resolutionflow_app` for user operations, `resolutionflow_admin` with BYPASSRLS for admin endpoints), admin database engine isolation, tenant context via `ContextVar` with automatic transaction-scoped enforcement, `account_id` column backfill on 35+ tables (sessions, AI branching, PSA, notifications, scripts, targets, folders), global content separation via platform account, fresh-DB migration order fixes - **Tenant Isolation Phase 2** — PostgreSQL Row Level Security (RLS) on 11 session-related tables (ai_sessions, session_steps, session_tags, etc.), account_id NOT NULL enforcement on all write paths, Alembic migrations with dual-env support (Railway native vars + explicit DATABASE_URL_SYNC), RLS test coverage with cross-account isolation verification, migration CI/CD integration
- **Tenant Isolation Phase 3** — RLS on audit_logs and tree_shares tables, cross-tenant session access for public shares (via get_admin_db), complete account_id propagation across PSA integration write paths, final RLS policy enforcement
- **Tenant Isolation Phase 4** (#136) — RLS enforcement on all 31 remaining tables (users, trees, teams, integrations, scripts, categories, templates, surveys, etc.), BYPASSRLS session pattern for auth deps and background jobs, admin session factory for startup routines (service accounts, seed data), global table exclusions (platform_steps, template_trees, script_categories, accounts), RLS tests with complete cross-tenant isolation verification, proper tree_shares ownership checks using tree owner's account_id
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library) - **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items - **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance - **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
@@ -24,7 +26,6 @@ All notable changes to ResolutionFlow are documented here.
- **Assistant Chat session actions** — moved Pause/Resume/Close actions from action bar to page header for consistency with FlowPilot - **Assistant Chat session actions** — moved Pause/Resume/Close actions from action bar to page header for consistency with FlowPilot
- **Design system token normalization** — unified FlowPilot, AssistantChat, and ScriptBuilder components to use consistent design tokens - **Design system token normalization** — unified FlowPilot, AssistantChat, and ScriptBuilder components to use consistent design tokens
- **Tenant data boundaries** — all session and tree endpoints now return 404 (not 403) for cross-tenant access attempts to avoid confirming resource existence - **Tenant data boundaries** — all session and tree endpoints now return 404 (not 403) for cross-tenant access attempts to avoid confirming resource existence
- **Admin database routing** — privileged operations (analytics, user management) now bypass RLS via dedicated admin engine
### Fixed ### Fixed
- **CRITICAL: Copilot tree query isolation** (#131) — user could access any tree UUID if known, exposing full tree structure to AI. Now scoped to current account with 404 for inaccessible trees. - **CRITICAL: Copilot tree query isolation** (#131) — user could access any tree UUID if known, exposing full tree structure to AI. Now scoped to current account with 404 for inaccessible trees.
@@ -33,6 +34,7 @@ All notable changes to ResolutionFlow are documented here.
- **Category tree counts** — cross-tenant row count leakage via tree_count field in GET `/categories/{id}`. Now scoped to requesting account. - **Category tree counts** — cross-tenant row count leakage via tree_count field in GET `/categories/{id}`. Now scoped to requesting account.
- **PSA retry ownership check** — retry-psa-push had no ownership validation (CRITICAL). Now validates user ownership before allowing retry. - **PSA retry ownership check** — retry-psa-push had no ownership validation (CRITICAL). Now validates user ownership before allowing retry.
- **Task Lane save operation** — invalid task_lane_item UUIDs returned 403 revealing existence. Now returns 404 and uses query-level filtering. - **Task Lane save operation** — invalid task_lane_item UUIDs returned 403 revealing existence. Now returns 404 and uses query-level filtering.
- **Phase 4 RLS enforcement** — fixed auth deps, user-mutation endpoints, background jobs, and lifespan routines to use BYPASSRLS sessions for reading/writing tenant-isolated tables; fixed seed scripts to use ADMIN_DATABASE_URL; bootstrap service account now initializes correctly with proper BYPASSRLS context
- Dark text rendering on blue accent step-number badges across all flow types - Dark text rendering on blue accent step-number badges across all flow types
- Script Library tab ownership filter now preserved across category and search changes - Script Library tab ownership filter now preserved across category and search changes
- Race conditions in script builder session creation and slug generation - Race conditions in script builder session creation and slug generation
@@ -43,7 +45,6 @@ All notable changes to ResolutionFlow are documented here.
- Task Lane stale data when creating new chat or resuming from concluded session - Task Lane stale data when creating new chat or resuming from concluded session
- Chat ref invalidation race condition between handleNewChat and async data loads - Chat ref invalidation race condition between handleNewChat and async data loads
- Images now properly display in chat message history instead of blank placeholders - Images now properly display in chat message history instead of blank placeholders
- Non-default, no-team trees now properly handled in global content migration
--- ---

116
CLAUDE.md
View File

@@ -222,10 +222,9 @@ docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
cd backend && pip install httpx && python -m scripts.seed_trees cd backend && pip install httpx && python -m scripts.seed_trees
# CI/CD debugging # CI/CD debugging
gh run list --limit 5 # Recent CI runs # CI runs on Gitea (gitea.resolutionflow.com), NOT GitHub Actions — gh run list will return nothing useful
gh run view <id> --log-failed # Failed job logs # Check CI status at: https://gitea.resolutionflow.com/chihlasm/resolutionflow/actions
gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusion}' # `gh` CLI is still used for GitHub Issues/PRs (mirrored repo), not for CI runs
# NEVER use `gh run watch` — it holds context open and burns tokens while waiting
``` ```
### URLs ### URLs
@@ -375,6 +374,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`). **106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
**107. Startup routines must use `_admin_session_factory()` after Phase 4 RLS:** Any code that runs at startup (lifespan, `ensure_service_account`, seed scripts) and touches tenant-isolated tables (`users`, etc.) must use `_admin_session_factory()` — not `get_db()`. Phase 4 enabled RLS on `users`; a tenant-scoped session has no `app.current_account_id` set at startup, so all queries return 0 rows or fail. `get_service_account_id` in `deps.py` is safe — it reads from `app.state` cached at startup, never hits the DB per-request.
**108. Tables with no `account_id` column (never add to RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts` — global/platform tables documented with "No account_id. No RLS." in their model files. When writing RLS migrations, scan at the class level (check for `account_id: Mapped` within the class block), not the file level — multiple classes in one `.py` file can have different columns (e.g. `ScriptCategory` vs `ScriptTemplate` in `script_template.py`).
**109. `tree_shares.account_id` must equal `tree.account_id`, not the actor's account:** When creating a `TreeShare`, always use `account_id=tree.account_id` (tree owner's tenant). A super admin in tenant A sharing tenant B's tree must produce a share row in tenant B's RLS context — using `current_user.account_id` instead makes the share invisible to the tree owner after RLS is enforced.
**110. Backfill migrations for `account_id` require a service-code audit:** When a migration adds `account_id` to an existing model via backfill (nullable → backfill → NOT NULL), grep for ALL `ModelClass(` instantiation sites in service code and verify `account_id=` is passed. SQLAlchemy accepts `None` silently with no warning; Phase 4 RLS WITH CHECK only surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`. Fixed example: `AISessionStep` — all 5 creation sites in `flowpilot_engine.py` were missing `account_id` until April 2026.
**111. Global Axios interceptor fires before component `.catch()` — fix optional-data endpoints at the source:** The global 5xx handler in `client.ts` fires for ALL non-401 5xx responses, even when a component does `.catch(() => {})`. If an endpoint returns optional UI data (e.g., board filters, PSA config), return `[]` / `{}` on provider failure rather than raising 502. Silencing the error in the component is not enough — the toast appears anyway. See `list_boards` in `integrations.py` for the fixed pattern.
## RBAC & Permissions ## RBAC & Permissions
- **Role hierarchy:** super_admin > team_admin > engineer > viewer - **Role hierarchy:** super_admin > team_admin > engineer > viewer
@@ -444,6 +453,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>` - Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name` - Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
- Large features: commit per phase with `npm run build` validation - Large features: commit per phase with `npm run build` validation
- **Remote is Gitea, not GitHub directly:** Push to `gitea.resolutionflow.com/chihlasm/resolutionflow`. Gitea auto-mirrors to GitHub via `.gitea/workflows/mirror-to-github.yml` — never push directly to GitHub.
### After Completing Work ### After Completing Work
@@ -491,7 +501,7 @@ When a feature, fix, or significant piece of work is finished and merged/committ
## Deployment (Railway) ## Deployment (Railway)
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend) - **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
- Auto-deploys on push to `main` - Auto-deploys via: push to Gitea → Gitea mirrors to GitHub → Railway watches GitHub `main` and deploys
- PR environments auto-created (need manual domain generation in Railway dashboard) - PR environments auto-created (need manual domain generation in Railway dashboard)
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service - PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app` - `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
@@ -519,104 +529,42 @@ When a feature, fix, or significant piece of work is finished and merged/committ
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | | Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking | | Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **resolutionflow** (14787 symbols, 31366 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **resolutionflow**. Use it selectively — for routine additive work (new endpoints, new components, isolated fixes) just read the files directly. GitNexus earns its cost when you're about to touch something genuinely central with many callers.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do ## When to Use It
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. **Use GitNexus when:**
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. - Touching a core shared symbol with many callers — `flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. - Renaming anything used across multiple files
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. - Tracing an unfamiliar bug through a call chain you haven't read
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - Assessing whether a refactor is safe before starting
## When Debugging **Skip GitNexus when:**
- Adding a new endpoint, component, or isolated feature
- Fixing a bug in a self-contained file
- Making changes you can already see the full scope of by reading the file
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue ## Useful Tools
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/resolutionflow/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command | | Tool | When to use | Command |
|------|-------------|---------| |------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | | `query` | Find code by concept when you don't know where to look | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | | `context` | See all callers/callees of a symbol before touching it | `gitnexus_context({name: "symbolName"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | | `impact` | Blast radius check before editing a shared symbol | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | | `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/resolutionflow/context` | Codebase overview, check index freshness |
| `gitnexus://repo/resolutionflow/clusters` | All functional areas |
| `gitnexus://repo/resolutionflow/processes` | All execution flows |
| `gitnexus://repo/resolutionflow/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh ## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: A PostToolUse hook re-indexes automatically after `git commit`. To manually refresh:
```bash ```bash
npx gitnexus analyze npx gitnexus analyze
``` ```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end --> <!-- gitnexus:end -->

View File

@@ -2,7 +2,7 @@
> **Purpose:** Quick-reference file showing exactly where the project stands. > **Purpose:** Quick-reference file showing exactly where the project stands.
> **For Claude Code:** Read this first to understand what's done and what's next. > **For Claude Code:** Read this first to understand what's done and what's next.
> **Last Updated:** March 23, 2026 > **Last Updated:** April 12, 2026
--- ---
@@ -163,6 +163,13 @@
- SQL wildcard escaping in tag search - SQL wildcard escaping in tag search
- PSA credentials encrypted at rest (Fernet) - PSA credentials encrypted at rest (Fernet)
### Tenant Isolation (Phases 1-4 Complete)
- PostgreSQL RLS enabled across tenant-scoped tables in phased rollout
- `account_id` propagation completed across core content, sessions, analytics, notifications, shares, and remaining Phase 4 tables
- Global platform tables correctly excluded from tenant RLS where they have no `account_id` (`script_categories`, `platform_steps`, `template_trees`)
- Runtime bootstrap paths updated to use BYPASSRLS/admin sessions where needed (auth/user mutations, startup service account, background jobs, seed scripts)
- Preview Railway backend and frontend deployments green for PR 136 after the Phase 4 fixes
### Copilot-First Dashboard (March 2026) ### Copilot-First Dashboard (March 2026)
- Redesigned dashboard as FlowPilot copilot launchpad (ChatGPT-style input) - Redesigned dashboard as FlowPilot copilot launchpad (ChatGPT-style input)

View File

@@ -29,13 +29,37 @@ from app.models.session_branch import SessionBranch # noqa: F401
from app.models.fork_point import ForkPoint # noqa: F401 from app.models.fork_point import ForkPoint # noqa: F401
from app.models.session_handoff import SessionHandoff # noqa: F401 from app.models.session_handoff import SessionHandoff # noqa: F401
from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401 from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401
from app.core.config import settings from app.core.config import settings
def _alembic_sync_url() -> str:
"""Return a psycopg2-compatible sync URL for Alembic.
Priority order:
1. DATABASE_URL_SYNC — in Railway this is set as a reference variable
(${{pgvector.DATABASE_URL}}) that resolves to the correct postgres
superuser credentials for the current environment (production, PR preview,
etc.). This always works even on fresh databases before any custom roles
have been created, because it uses the postgres superuser.
2. ADMIN_DATABASE_URL (resolutionflow_admin, BYPASSRLS) converted to a sync
driver — fallback for local dev where DATABASE_URL_SYNC may not be set.
"""
if settings.DATABASE_URL_SYNC:
return settings.DATABASE_URL_SYNC
admin_url = settings.ADMIN_DATABASE_URL
if admin_url and "+asyncpg" in admin_url:
return admin_url.replace("postgresql+asyncpg://", "postgresql://")
return settings.DATABASE_URL_SYNC
# this is the Alembic Config object # this is the Alembic Config object
config = context.config config = context.config
# Override sqlalchemy.url with the sync version for migrations # Override sqlalchemy.url with the sync version for migrations
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC) config.set_main_option("sqlalchemy.url", _alembic_sync_url())
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
if config.config_file_name is not None: if config.config_file_name is not None:
@@ -86,7 +110,7 @@ def run_migrations_online() -> None:
from sqlalchemy import create_engine from sqlalchemy import create_engine
connectable = create_engine( connectable = create_engine(
settings.DATABASE_URL_SYNC, _alembic_sync_url(),
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )

View File

@@ -0,0 +1,59 @@
"""Enable RLS on Phase 3 tables.
Tables covered:
- step_ratings (account_id NOT NULL since migration 7167e9374b0c)
- step_usage_log (account_id NOT NULL since migration 7167e9374b0c)
- target_lists (account_id NOT NULL since migration 2c6aabd89bc6)
- session_shares (account_id NOT NULL since session_share model)
- audit_logs (account_id NOT NULL since migration 2a9056eddd90)
- tree_shares (account_id NOT NULL since migration a05e1a1bea7c)
All use a standard intra-tenant isolation policy.
Token-based access to session_shares and tree_shares goes through
endpoints that use get_admin_db (BYPASSRLS), so a strict tenant
policy here is correct.
Revision ID: 04f013768235
Revises: a05e1a1bea7c
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = '04f013768235'
down_revision: Union[str, None] = 'a05e1a1bea7c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_CURRENT_ACCOUNT = (
"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000')::uuid"
)
_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}"
_PHASE3_TABLES = [
"step_ratings",
"step_usage_log",
"target_lists",
"session_shares",
"audit_logs",
"tree_shares",
]
def upgrade() -> None:
for table in _PHASE3_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_STANDARD_USING})
""")
def downgrade() -> None:
for table in _PHASE3_TABLES:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")

View File

@@ -0,0 +1,132 @@
"""Add account-scoped device_types table with platform seed data.
Revision ID: 073
Revises: b3c7e9f2a1d8
Create Date: 2026-04-12
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
import uuid
revision = "073"
down_revision = "b3c7e9f2a1d8"
branch_labels = None
depends_on = None
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
_CURRENT_ACCOUNT = (
"COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
SYSTEM_DEVICE_TYPES = [
("router", "Router", "network", 0),
("switch", "Switch", "network", 1),
("firewall", "Firewall", "network", 2),
("access-point", "Access Point", "network", 3),
("load-balancer", "Load Balancer", "network", 4),
("server", "Server", "compute", 0),
("workstation", "Workstation", "compute", 1),
("vm", "Virtual Machine", "compute", 2),
("container", "Container", "compute", 3),
("nas", "NAS", "storage", 0),
("san", "SAN", "storage", 1),
("cloud-storage", "Cloud Storage", "storage", 2),
("cloud", "Cloud", "cloud", 0),
("aws", "AWS", "cloud", 1),
("azure", "Azure", "cloud", 2),
("gcp", "Google Cloud", "cloud", 3),
("printer", "Printer", "endpoint", 0),
("phone", "Phone", "endpoint", 1),
("iot", "IoT Device", "endpoint", 2),
("camera", "Camera", "endpoint", 3),
("tablet", "Tablet", "endpoint", 4),
("laptop", "Laptop", "endpoint", 5),
("ups", "UPS", "infrastructure", 0),
("pdu", "PDU", "infrastructure", 1),
("rack", "Rack", "infrastructure", 2),
("patch-panel", "Patch Panel", "infrastructure", 3),
("nvr", "NVR", "security", 0),
("badge-reader", "Badge Reader", "security", 1),
]
def upgrade() -> None:
op.create_table(
"device_types",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("slug", sa.String(50), nullable=False),
sa.Column("label", sa.String(100), nullable=False),
sa.Column("category", sa.String(50), nullable=False),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_unique_constraint("uq_device_types_slug_account", "device_types", ["slug", "account_id"])
op.create_index("ix_device_types_account_id", "device_types", ["account_id"])
device_types_table = sa.table(
"device_types",
sa.column("id", UUID(as_uuid=True)),
sa.column("slug", sa.String),
sa.column("label", sa.String),
sa.column("category", sa.String),
sa.column("is_system", sa.Boolean),
sa.column("account_id", UUID(as_uuid=True)),
sa.column("sort_order", sa.Integer),
)
op.bulk_insert(device_types_table, [
{
"id": uuid.uuid4(),
"slug": slug,
"label": label,
"category": category,
"is_system": True,
"account_id": uuid.UUID(_PLATFORM_UUID),
"sort_order": sort_order,
}
for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
])
op.execute("ALTER TABLE device_types ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE device_types FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY device_types_select ON device_types
FOR SELECT
USING (
account_id = {_CURRENT_ACCOUNT}
OR account_id = '{_PLATFORM_UUID}'::uuid
)
""")
op.execute(f"""
CREATE POLICY device_types_insert ON device_types
FOR INSERT
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
op.execute(f"""
CREATE POLICY device_types_update ON device_types
FOR UPDATE
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
op.execute(f"""
CREATE POLICY device_types_delete ON device_types
FOR DELETE
USING (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS device_types_delete ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_update ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_insert ON device_types")
op.execute("DROP POLICY IF EXISTS device_types_select ON device_types")
op.execute("ALTER TABLE device_types DISABLE ROW LEVEL SECURITY")
op.drop_table("device_types")

View File

@@ -0,0 +1,57 @@
"""Add network_diagrams table.
Revision ID: 074
Revises: 073
Create Date: 2026-04-12
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "074"
down_revision = "073"
branch_labels = None
depends_on = None
_CURRENT_ACCOUNT = (
"COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
def upgrade() -> None:
op.create_table(
"network_diagrams",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("client_name", sa.String(255), nullable=True),
sa.Column("asset_name", sa.String(255), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("nodes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("edges", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("thumbnail_url", sa.Text(), nullable=True),
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_index("ix_network_diagrams_account_id", "network_diagrams", ["account_id"])
op.create_index("idx_network_diagrams_account_client", "network_diagrams", ["account_id", "client_name"])
op.execute("ALTER TABLE network_diagrams ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE network_diagrams FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON network_diagrams
USING (account_id = {_CURRENT_ACCOUNT})
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
""")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON network_diagrams")
op.execute("ALTER TABLE network_diagrams DISABLE ROW LEVEL SECURITY")
op.drop_table("network_diagrams")

View File

@@ -0,0 +1,32 @@
"""Drop team_id from target_lists.
account_id (NOT NULL) is now the tenant isolation key; team_id is redundant.
All reads/writes use account_id via RLS + application filter.
Revision ID: 172ad76d7d20
Revises: 04f013768235
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '172ad76d7d20'
down_revision: Union[str, None] = '04f013768235'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_index('ix_target_lists_team_id', table_name='target_lists', if_exists=True)
op.drop_constraint('target_lists_team_id_fkey', 'target_lists', type_='foreignkey')
op.drop_column('target_lists', 'team_id')
def downgrade() -> None:
op.add_column('target_lists', sa.Column('team_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'target_lists_team_id_fkey', 'target_lists', 'teams',
['team_id'], ['id'], ondelete='CASCADE',
)
op.create_index('ix_target_lists_team_id', 'target_lists', ['team_id'])

View File

@@ -0,0 +1,51 @@
"""Add account_id to audit_logs and backfill via user_id.
Revision ID: 2a9056eddd90
Revises: 70a5dd746e83
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '2a9056eddd90'
down_revision: Union[str, None] = '70a5dd746e83'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('audit_logs', sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_audit_logs_account_id', 'audit_logs', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Backfill: derive from the acting user's account
op.execute("""
UPDATE audit_logs al
SET account_id = u.account_id
FROM users u
WHERE al.user_id = u.id
AND u.account_id IS NOT NULL
AND al.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM audit_logs WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} audit_logs rows have NULL account_id after backfill. "
"All audit log entries must have an associated user with an account."
)
op.alter_column('audit_logs', 'account_id', nullable=False)
op.create_index('ix_audit_logs_account_id', 'audit_logs', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_audit_logs_account_id', table_name='audit_logs')
op.drop_constraint('fk_audit_logs_account_id', 'audit_logs', type_='foreignkey')
op.drop_column('audit_logs', 'account_id')

View File

@@ -0,0 +1,90 @@
"""Enable RLS on Phase 2 session and supporting tables.
10 tables use a standard tenant-only policy.
step_library uses a visibility-aware policy — public steps visible to all tenants.
NOTE: session_messages does not exist in this codebase (removed from plan).
script_generations is the correct table name (not script_template_generations).
sessions and ai_sessions are two separate tables, both in scope.
Prerequisites:
- Phase 1 migration must have run (resolutionflow_app role exists, Phase 1 tables have RLS)
- NOT NULL write-path bugs fixed (P2-A commits b641ac6)
- shares.py cross-tenant session fix deployed (P2-B commit ac2b193)
Revision ID: 70a5dd746e83
Revises: c5f48b9890f9
Create Date: 2026-04-10 06:54:49.431817
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '70a5dd746e83'
down_revision: Union[str, None] = 'c5f48b9890f9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
_CURRENT_ACCOUNT = (
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
f"'{_NULL_UUID}')::uuid"
)
# Standard tenant-only policy — account_id must match the current tenant.
# When no tenant context is set, COALESCE returns the nil UUID so zero rows
# are visible (fail-closed).
_STANDARD_USING = f"account_id = {_CURRENT_ACCOUNT}"
# Visibility-aware policy for step_library — public steps (visibility='public')
# must be visible to ALL tenants regardless of account_id. This covers the
# visibility='public' arm of build_step_visibility_filter() in app/core/filters.py.
# The created_by arm (private steps visible to their author) is covered
# transitively: private steps share account_id with their creator, so the
# account_id match handles it. This relies on account_id NOT NULL on step_library.
_STEP_LIBRARY_USING = f"account_id = {_CURRENT_ACCOUNT} OR visibility = 'public'"
# Standard tables: strict tenant isolation, no cross-tenant visibility.
_STANDARD_TABLES = [
"sessions",
"ai_sessions",
"session_branches",
"session_supporting_data",
"session_resolution_outputs",
"session_handoffs",
"script_templates",
"script_generations",
"maintenance_schedules",
"psa_post_log",
]
def upgrade() -> None:
# ── Standard tenant-isolation tables ────────────────────────────────────
for table in _STANDARD_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_STANDARD_USING})
""")
# ── step_library ────────────────────────────────────────────────────────
# Public steps (visibility='public') must be readable by all tenants so
# the Solutions Library browsing experience works without tenant context.
# Private/team steps remain tenant-scoped.
op.execute("ALTER TABLE step_library ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE step_library FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON step_library
USING ({_STEP_LIBRARY_USING})
""")
def downgrade() -> None:
for table in _STANDARD_TABLES + ["step_library"]:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")

View File

@@ -0,0 +1,57 @@
"""Add account_id to tree_shares and backfill via tree owner's account.
The share belongs to the tree's tenant, not the actor who created it.
A super admin in account A can share a tree owned by account B; that share
must land in account B so account B's RLS filter sees it.
Revision ID: a05e1a1bea7c
Revises: 2a9056eddd90
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a05e1a1bea7c'
down_revision: Union[str, None] = '2a9056eddd90'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('tree_shares', sa.Column('account_id', sa.UUID(), nullable=True))
op.create_foreign_key(
'fk_tree_shares_account_id', 'tree_shares', 'accounts',
['account_id'], ['id'], ondelete='CASCADE',
)
# Backfill: derive from the tree's account, not the creator's account.
# A share lives in the same tenant as its tree so that the tree owner's
# RLS context covers their own shares regardless of who created them.
op.execute("""
UPDATE tree_shares ts
SET account_id = t.account_id
FROM trees t
WHERE ts.tree_id = t.id
AND t.account_id IS NOT NULL
AND ts.account_id IS NULL
""")
result = op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM tree_shares WHERE account_id IS NULL")
)
count = result.scalar()
if count > 0:
raise RuntimeError(
f"ROLLBACK: {count} tree_shares rows have NULL account_id after backfill. "
"All share entries must have a creating user with an account."
)
op.alter_column('tree_shares', 'account_id', nullable=False)
op.create_index('ix_tree_shares_account_id', 'tree_shares', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_tree_shares_account_id', table_name='tree_shares')
op.drop_constraint('fk_tree_shares_account_id', 'tree_shares', type_='foreignkey')
op.drop_column('tree_shares', 'account_id')

View File

@@ -0,0 +1,85 @@
"""Enable RLS on Phase 4 tables — all remaining tenant-scoped tables.
All tables in this migration already have account_id NOT NULL (enforced by
earlier migrations). This migration adds ENABLE ROW LEVEL SECURITY,
FORCE ROW LEVEL SECURITY, and the appropriate tenant isolation policy to each.
Policy variants used:
- Standard: account_id = current_setting(app.current_account_id)::uuid
- Platform: standard OR account_id = PLATFORM_ACCOUNT_ID
(for global content tables readable by all tenants)
Skipped intentionally:
- accounts — IS the root table; no account_id column
- plan_feature_defaults — platform config; no account_id column
- script_categories — global lookup table; no account_id column
- platform_steps — global content; no account_id column (readable by all)
- template_trees — global content; no account_id column (readable by all)
Revision ID: b3c7e9f2a1d8
Revises: 172ad76d7d20
Create Date: 2026-04-12
"""
from typing import Union
from alembic import op
revision: str = "b3c7e9f2a1d8"
down_revision: Union[str, None] = "172ad76d7d20"
branch_labels = None
depends_on = None
# Standard policy — tenant sees only own rows.
_STANDARD_TABLES = [
"users",
"account_invites",
"account_limit_overrides",
"account_feature_overrides",
"subscriptions",
"ai_chat_sessions",
"ai_conversations",
"ai_session_steps",
"ai_session_embeddings",
"ai_suggestions",
"ai_usage",
"assistant_chats",
"attachments",
"copilot_conversations",
"feedback",
"file_uploads",
"fork_points",
"kb_imports",
"notifications",
"notification_configs",
"notification_logs",
"psa_activity_logs",
"psa_member_mappings",
"script_builder_sessions",
"session_ratings",
"tree_embeddings",
"user_folders",
"user_pinned_trees",
]
_POLICY_EXPR = (
"account_id = COALESCE("
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
"'00000000-0000-0000-0000-000000000000'"
")::uuid"
)
def upgrade() -> None:
for table in _STANDARD_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"""
CREATE POLICY tenant_isolation ON {table}
USING ({_POLICY_EXPR})
""")
def downgrade() -> None:
for table in _STANDARD_TABLES:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")

View File

@@ -24,10 +24,14 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user( async def get_current_user(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_admin_db)],
token: Annotated[str, Depends(oauth2_scheme)] token: Annotated[str, Depends(oauth2_scheme)]
) -> User: ) -> User:
"""Get current authenticated user from JWT token.""" """Get current authenticated user from JWT token.
Must use get_admin_db (BYPASSRLS): this dep runs before require_tenant_context
sets app.current_account_id, so the users table RLS would block the lookup.
"""
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
@@ -77,10 +81,14 @@ async def get_refresh_token_payload(
async def get_current_active_user( async def get_current_active_user(
request: Request, request: Request,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> User: ) -> User:
"""Ensure user is active (not disabled). Auto-downgrades expired trials. """Ensure user is active (not disabled). Auto-downgrades expired trials.
Enforces must_change_password — blocks all routes except allowlist.""" Enforces must_change_password — blocks all routes except allowlist.
Uses get_admin_db: runs before require_tenant_context sets the ContextVar,
so tenant-scoped tables (subscriptions) would return 0 rows via app role.
"""
if not current_user.is_active: if not current_user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,

View File

@@ -9,6 +9,7 @@ from sqlalchemy import select
from pydantic import BaseModel from pydantic import BaseModel
from app.core.database import get_db from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage
from app.core.audit import log_audit from app.core.audit import log_audit
from app.models.refresh_token import RefreshToken from app.models.refresh_token import RefreshToken
@@ -148,7 +149,7 @@ async def update_member_role(
@router.post("/me/transfer-ownership", response_model=AccountResponse) @router.post("/me/transfer-ownership", response_model=AccountResponse)
async def transfer_ownership( async def transfer_ownership(
data: TransferOwnershipRequest, data: TransferOwnershipRequest,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_account_owner)] current_user: Annotated[User, Depends(require_account_owner)]
): ):
"""Transfer account ownership to another member (owner only).""" """Transfer account ownership to another member (owner only)."""
@@ -377,7 +378,7 @@ async def list_invites(
@router.post("/me/leave") @router.post("/me/leave")
async def leave_account( async def leave_account(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(get_current_active_user)] current_user: Annotated[User, Depends(get_current_active_user)]
): ):
"""Leave the current account (non-owners only). Creates a personal account.""" """Leave the current account (non-owners only). Creates a personal account."""
@@ -423,7 +424,7 @@ class DeleteAccountRequest(BaseModel):
@router.delete("/me") @router.delete("/me")
async def delete_account( async def delete_account(
data: DeleteAccountRequest, data: DeleteAccountRequest,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_account_owner)] current_user: Annotated[User, Depends(require_account_owner)]
): ):
"""Delete the current account and soft-delete the user (owner only, no other members).""" """Delete the current account and soft-delete the user (owner only, no other members)."""

View File

@@ -5,8 +5,8 @@ from typing import Annotated, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload, aliased
from app.core.admin_database import get_admin_db from app.core.admin_database import get_admin_db
from app.core.audit import log_audit from app.core.audit import log_audit
@@ -24,21 +24,44 @@ from app.models.invite_code import InviteCode
from app.models.account_invite import AccountInvite from app.models.account_invite import AccountInvite
from app.models.tree import Tree from app.models.tree import Tree
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
from app.schemas.admin import MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse from app.schemas.admin import (
MoveUserAccount,
AdminUserCreate,
AdminUserCreateResponse,
AdminPasswordReset,
AdminPasswordResetResponse,
HardDeleteCheckResponse,
AdminUserListItem,
AdminUserListResponse,
AdminAccountMember,
AdminAccountListItem,
AdminAccountListResponse,
AdminAccountOwnerSummary,
AdminAccountSubscriptionSummary,
AdminAccountUsageSummary,
AdminAccountDetailResponse,
AdminAccountInviteSummary,
AdminAccountCreate,
AdminAccountUpdate,
)
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
from app.schemas.user_detail import ( from app.schemas.user_detail import (
UserDetailResponse, AccountSummary, SubscriptionSummary, UserDetailResponse, AccountSummary, SubscriptionSummary,
SessionSummary, AuditLogSummary, InviteCodeUsedSummary, SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
) )
from app.api.deps import require_admin from app.api.deps import require_admin
from app.core.subscriptions import get_account_usage
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/users", response_model=list[UserResponse]) @router.get("/users", response_model=AdminUserListResponse)
async def list_users( async def list_users(
db: Annotated[AsyncSession, Depends(get_admin_db)], db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)], current_user: Annotated[User, Depends(require_admin)],
page: Optional[int] = Query(None, ge=1),
size: Optional[int] = Query(None, ge=1, le=100),
search: Optional[str] = Query(None, description="Search by user or account fields"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100), limit: int = Query(100, ge=1, le=100),
is_active: Optional[bool] = Query(None, description="Filter by active status"), is_active: Optional[bool] = Query(None, description="Filter by active status"),
@@ -46,23 +69,240 @@ async def list_users(
account_id: Optional[UUID] = Query(None, description="Filter by account"), account_id: Optional[UUID] = Query(None, description="Filter by account"),
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"), include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
): ):
"""List all users (super admin only).""" """List users for super admin global people search."""
query = select(User) resolved_limit = size or limit
resolved_skip = skip
current_page = 1
if page is not None:
resolved_skip = (page - 1) * resolved_limit
current_page = page
elif resolved_limit > 0:
current_page = (resolved_skip // resolved_limit) + 1
count_query = (
select(func.count())
.select_from(User)
.outerjoin(Account, User.account_id == Account.id)
)
query = (
select(
User,
Account.name.label("account_name"),
Account.display_code.label("account_display_code"),
)
.outerjoin(Account, User.account_id == Account.id)
)
if not include_archived: if not include_archived:
query = query.where(User.deleted_at.is_(None)) query = query.where(User.deleted_at.is_(None))
count_query = count_query.where(User.deleted_at.is_(None))
if is_active is not None: if is_active is not None:
query = query.where(User.is_active == is_active) query = query.where(User.is_active == is_active)
count_query = count_query.where(User.is_active == is_active)
if role: if role:
query = query.where(User.role == role) query = query.where(User.role == role)
count_query = count_query.where(User.role == role)
if account_id: if account_id:
query = query.where(User.account_id == account_id) query = query.where(User.account_id == account_id)
count_query = count_query.where(User.account_id == account_id)
if search:
search_term = f"%{search.strip()}%"
search_filter = or_(
User.name.ilike(search_term),
User.email.ilike(search_term),
Account.name.ilike(search_term),
Account.display_code.ilike(search_term),
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
query = query.order_by(User.created_at.desc()).offset(skip).limit(limit) total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = query.order_by(User.created_at.desc()).offset(resolved_skip).limit(resolved_limit)
result = await db.execute(query) result = await db.execute(query)
users = result.scalars().all() rows = result.all()
return users
items = [
AdminUserListItem(
id=user.id,
email=user.email,
name=user.name,
role=user.role,
is_super_admin=user.is_super_admin,
is_active=user.is_active,
account_id=user.account_id,
account_role=user.account_role,
account_name=account_name,
account_display_code=account_display_code,
created_at=user.created_at,
last_login=user.last_login,
deleted_at=user.deleted_at,
)
for user, account_name, account_display_code in rows
]
return AdminUserListResponse(
items=items,
total=total,
page=current_page,
per_page=resolved_limit,
)
@router.get("/accounts", response_model=AdminAccountListResponse)
async def list_accounts(
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
page: int = Query(1, ge=1),
size: int = Query(12, ge=1, le=100),
search: Optional[str] = Query(None, description="Search by account, display code, or owner"),
plan: Optional[str] = Query(None, description="Filter by subscription plan"),
status: Optional[str] = Query(None, description="Filter by subscription status"),
include_archived: bool = Query(False, description="Include archived users in account member lists"),
):
"""List accounts with embedded members for the admin panel."""
owner_user = aliased(User)
count_query = (
select(func.count(func.distinct(Account.id)))
.select_from(Account)
.outerjoin(owner_user, Account.owner_id == owner_user.id)
.outerjoin(Subscription, Subscription.account_id == Account.id)
)
accounts_query = (
select(
Account,
owner_user.id.label("owner_user_id"),
owner_user.name.label("owner_name"),
owner_user.email.label("owner_email"),
Subscription.id.label("subscription_id"),
Subscription.plan.label("subscription_plan"),
Subscription.status.label("subscription_status"),
Subscription.billing_interval.label("subscription_billing_interval"),
Subscription.current_period_end.label("subscription_current_period_end"),
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
)
.outerjoin(owner_user, Account.owner_id == owner_user.id)
.outerjoin(Subscription, Subscription.account_id == Account.id)
)
if search:
search_term = f"%{search.strip()}%"
search_filter = or_(
Account.name.ilike(search_term),
Account.display_code.ilike(search_term),
owner_user.name.ilike(search_term),
owner_user.email.ilike(search_term),
)
count_query = count_query.where(search_filter)
accounts_query = accounts_query.where(search_filter)
if plan:
count_query = count_query.where(Subscription.plan == plan)
accounts_query = accounts_query.where(Subscription.plan == plan)
if status:
count_query = count_query.where(Subscription.status == status)
accounts_query = accounts_query.where(Subscription.status == status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
accounts_result = await db.execute(
accounts_query
.order_by(Account.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
rows = accounts_result.all()
accounts = [row.Account for row in rows]
account_ids = [account.id for account in accounts]
members_by_account: dict[UUID, list[AdminAccountMember]] = {account_id: [] for account_id in account_ids}
pending_invites_by_account: dict[UUID, int] = {account_id: 0 for account_id in account_ids}
usage_by_account: dict[UUID, AdminAccountUsageSummary] = {}
if account_ids:
members_query = select(User).where(User.account_id.in_(account_ids))
if not include_archived:
members_query = members_query.where(User.deleted_at.is_(None))
members_query = members_query.order_by(User.created_at.asc())
members_result = await db.execute(members_query)
for member in members_result.scalars().all():
members_by_account.setdefault(member.account_id, []).append(
AdminAccountMember(
id=member.id,
email=member.email,
name=member.name,
role=member.role,
is_super_admin=member.is_super_admin,
is_active=member.is_active,
account_role=member.account_role,
created_at=member.created_at,
last_login=member.last_login,
deleted_at=member.deleted_at,
)
)
pending_invites_result = await db.execute(
select(AccountInvite.account_id, func.count(AccountInvite.id))
.where(
AccountInvite.account_id.in_(account_ids),
AccountInvite.used_at.is_(None),
)
.group_by(AccountInvite.account_id)
)
pending_invites_by_account.update({row[0]: row[1] for row in pending_invites_result.all()})
for account_id in account_ids:
usage = await get_account_usage(account_id, db)
usage_by_account[account_id] = AdminAccountUsageSummary(
tree_count=usage.get("tree_count", 0),
session_count_this_month=usage.get("session_count_this_month", 0),
)
items = [
AdminAccountListItem(
id=row.Account.id,
name=row.Account.name,
display_code=row.Account.display_code,
created_at=row.Account.created_at,
owner_id=row.Account.owner_id,
owner=(
AdminAccountOwnerSummary(
id=row.owner_user_id,
name=row.owner_name,
email=row.owner_email,
) if row.owner_user_id and row.owner_name and row.owner_email else None
),
subscription=(
AdminAccountSubscriptionSummary(
id=row.subscription_id,
plan=row.subscription_plan,
status=row.subscription_status,
billing_interval=row.subscription_billing_interval,
current_period_end=row.subscription_current_period_end,
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
),
usage=usage_by_account.get(row.Account.id, AdminAccountUsageSummary()),
member_count=len(members_by_account.get(row.Account.id, [])),
active_member_count=sum(1 for member in members_by_account.get(row.Account.id, []) if member.is_active),
pending_invite_count=pending_invites_by_account.get(row.Account.id, 0),
sso_enabled=row.Account.sso_enabled,
branding_company_name=row.Account.branding_company_name,
members=members_by_account.get(row.Account.id, []),
)
for row in rows
]
return AdminAccountListResponse(
items=items,
total=total,
page=page,
per_page=size,
)
def _generate_display_code() -> str: def _generate_display_code() -> str:
@@ -71,6 +311,192 @@ def _generate_display_code() -> str:
return ''.join(secrets.choice(chars) for _ in range(8)) return ''.join(secrets.choice(chars) for _ in range(8))
async def _generate_unique_display_code(db: AsyncSession) -> str:
"""Generate a unique display code for a new account."""
while True:
display_code = _generate_display_code()
existing = await db.execute(select(Account.id).where(Account.display_code == display_code))
if existing.scalar_one_or_none() is None:
return display_code
async def _get_account_detail_payload(
account_id: UUID,
db: AsyncSession,
include_archived: bool = False,
) -> AdminAccountDetailResponse:
owner_user = aliased(User)
result = await db.execute(
select(
Account,
owner_user.id.label("owner_user_id"),
owner_user.name.label("owner_name"),
owner_user.email.label("owner_email"),
Subscription.id.label("subscription_id"),
Subscription.plan.label("subscription_plan"),
Subscription.status.label("subscription_status"),
Subscription.billing_interval.label("subscription_billing_interval"),
Subscription.current_period_end.label("subscription_current_period_end"),
Subscription.cancel_at_period_end.label("subscription_cancel_at_period_end"),
)
.outerjoin(owner_user, Account.owner_id == owner_user.id)
.outerjoin(Subscription, Subscription.account_id == Account.id)
.where(Account.id == account_id)
)
row = result.one_or_none()
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
members_query = select(User).where(User.account_id == account_id).order_by(User.created_at.asc())
if not include_archived:
members_query = members_query.where(User.deleted_at.is_(None))
members_result = await db.execute(members_query)
members = [
AdminAccountMember(
id=member.id,
email=member.email,
name=member.name,
role=member.role,
is_super_admin=member.is_super_admin,
is_active=member.is_active,
account_role=member.account_role,
created_at=member.created_at,
last_login=member.last_login,
deleted_at=member.deleted_at,
)
for member in members_result.scalars().all()
]
invites_result = await db.execute(
select(AccountInvite)
.where(AccountInvite.account_id == account_id)
.order_by(AccountInvite.created_at.desc())
)
invites = [
AdminAccountInviteSummary(
id=invite.id,
email=invite.email,
role=invite.role,
expires_at=invite.expires_at,
created_at=invite.created_at,
used_at=invite.used_at,
)
for invite in invites_result.scalars().all()
if invite.used_at is None
]
usage = await get_account_usage(account_id, db)
return AdminAccountDetailResponse(
id=row.Account.id,
name=row.Account.name,
display_code=row.Account.display_code,
created_at=row.Account.created_at,
owner_id=row.Account.owner_id,
owner=(
AdminAccountOwnerSummary(
id=row.owner_user_id,
name=row.owner_name,
email=row.owner_email,
) if row.owner_user_id and row.owner_name and row.owner_email else None
),
subscription=(
AdminAccountSubscriptionSummary(
id=row.subscription_id,
plan=row.subscription_plan,
status=row.subscription_status,
billing_interval=row.subscription_billing_interval,
current_period_end=row.subscription_current_period_end,
cancel_at_period_end=row.subscription_cancel_at_period_end or False,
) if row.subscription_id and row.subscription_plan and row.subscription_status else None
),
usage=AdminAccountUsageSummary(
tree_count=usage.get("tree_count", 0),
session_count_this_month=usage.get("session_count_this_month", 0),
),
member_count=len(members),
active_member_count=sum(1 for member in members if member.is_active),
pending_invite_count=len(invites),
sso_enabled=row.Account.sso_enabled,
branding_company_name=row.Account.branding_company_name,
members=members,
invites=invites,
)
@router.post("/accounts", response_model=AdminAccountDetailResponse, status_code=status.HTTP_201_CREATED)
async def create_account(
data: AdminAccountCreate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new account without requiring an initial user."""
owner_id = None
if data.owner_email:
result = await db.execute(select(User).where(User.email == data.owner_email.strip()))
owner = result.scalar_one_or_none()
if not owner:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No user found with email '{data.owner_email}'")
owner_id = owner.id
display_code = await _generate_unique_display_code(db)
new_account = Account(
name=data.name.strip(),
display_code=display_code,
owner_id=owner_id,
)
db.add(new_account)
await db.flush()
new_subscription = Subscription(
account_id=new_account.id,
plan=data.plan,
status="active",
)
db.add(new_subscription)
await log_audit(
db, current_user.id, "account.create_admin", "account", new_account.id,
{"name": new_account.name, "plan": data.plan, "owner_email": data.owner_email},
)
await db.commit()
return await _get_account_detail_payload(new_account.id, db)
@router.get("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
async def get_account_detail(
account_id: UUID,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
include_archived: bool = Query(False),
):
"""Get detailed account information for admin management."""
return await _get_account_detail_payload(account_id, db, include_archived=include_archived)
@router.put("/accounts/{account_id}", response_model=AdminAccountDetailResponse)
async def update_account(
account_id: UUID,
data: AdminAccountUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update account settings from the admin panel."""
result = await db.execute(select(Account).where(Account.id == account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
old_name = account.name
account.name = data.name.strip()
await log_audit(
db, current_user.id, "account.update_admin", "account", account.id,
{"old_name": old_name, "new_name": account.name},
)
await db.commit()
return await _get_account_detail_payload(account.id, db)
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED) @router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_user( async def create_user(
data: AdminUserCreate, data: AdminUserCreate,
@@ -516,6 +942,28 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
return user, subscription return user, subscription
async def _get_account_subscription(account_id: UUID, db: AsyncSession) -> tuple[Account, Subscription]:
"""Helper to load account and its subscription."""
account_result = await db.execute(select(Account).where(Account.id == account_id))
account = account_result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
sub_result = await db.execute(
select(Subscription).where(Subscription.account_id == account.id)
)
subscription = sub_result.scalar_one_or_none()
if not subscription:
subscription = Subscription(
account_id=account.id,
plan="free",
status="active",
)
db.add(subscription)
await db.flush()
return account, subscription
@router.put("/users/{user_id}/subscription/plan") @router.put("/users/{user_id}/subscription/plan")
async def update_user_plan( async def update_user_plan(
user_id: UUID, user_id: UUID,
@@ -535,6 +983,31 @@ async def update_user_plan(
return {"plan": subscription.plan, "status": subscription.status} return {"plan": subscription.plan, "status": subscription.status}
@router.put("/accounts/{account_id}/subscription/plan")
async def update_account_plan(
account_id: UUID,
data: SubscriptionPlanUpdate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Change an account subscription plan (super admin only)."""
if data.plan not in ("free", "pro", "team"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
account, subscription = await _get_account_subscription(account_id, db)
old_plan = subscription.plan
subscription.plan = data.plan
await log_audit(
db,
current_user.id,
"subscription.plan_change",
"subscription",
subscription.id,
{"old_plan": old_plan, "new_plan": data.plan, "account_id": str(account_id)},
)
await db.commit()
return {"plan": subscription.plan, "status": subscription.status}
@router.put("/users/{user_id}/subscription/extend-trial") @router.put("/users/{user_id}/subscription/extend-trial")
async def extend_user_trial( async def extend_user_trial(
user_id: UUID, user_id: UUID,
@@ -565,6 +1038,43 @@ async def extend_user_trial(
"current_period_end": subscription.current_period_end} "current_period_end": subscription.current_period_end}
@router.put("/accounts/{account_id}/subscription/extend-trial")
async def extend_account_trial(
account_id: UUID,
data: ExtendTrialRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Extend or start a trial for an account subscription (super admin only)."""
if data.days < 1 or data.days > 90:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Days must be 1-90")
account, subscription = await _get_account_subscription(account_id, db)
now = datetime.now(timezone.utc)
if subscription.status == "trialing" and subscription.current_period_end:
new_end = subscription.current_period_end + timedelta(days=data.days)
else:
subscription.status = "trialing"
subscription.current_period_start = now
new_end = now + timedelta(days=data.days)
subscription.current_period_end = new_end
await log_audit(
db,
current_user.id,
"subscription.extend_trial",
"subscription",
subscription.id,
{"days": data.days, "new_end": new_end.isoformat(), "account_id": str(account.id)},
)
await db.commit()
return {
"plan": subscription.plan,
"status": subscription.status,
"current_period_end": subscription.current_period_end,
}
@router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse) @router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse)
async def admin_reset_password( async def admin_reset_password(
user_id: UUID, user_id: UUID,

View File

@@ -43,6 +43,7 @@ async def create_suggestion(
suggestion = AISuggestion( suggestion = AISuggestion(
tree_id=data.tree_id, tree_id=data.tree_id,
user_id=current_user.id, user_id=current_user.id,
account_id=current_user.account_id,
session_id=data.session_id, session_id=data.session_id,
action_type=data.action_type, action_type=data.action_type,
target_node_id=data.target_node_id, target_node_id=data.target_node_id,

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sa_update from sqlalchemy import select, update as sa_update
from app.core.config import settings from app.core.config import settings
from app.core.settings_manager import SettingsManager from app.core.settings_manager import SettingsManager
from app.core.database import get_db from app.core.admin_database import get_admin_db
from app.core.rate_limit import limiter from app.core.rate_limit import limiter
from app.core.security import ( from app.core.security import (
verify_password, verify_password,
@@ -67,7 +67,7 @@ def _generate_display_code() -> str:
async def register( async def register(
request: Request, request: Request,
user_data: UserCreate, user_data: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Register a new user. """Register a new user.
@@ -232,7 +232,7 @@ async def register(
async def login( async def login(
request: Request, request: Request,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Login and get access token.""" """Login and get access token."""
# Find user by email # Find user by email
@@ -270,7 +270,7 @@ async def login(
async def login_json( async def login_json(
request: Request, request: Request,
credentials: UserLogin, credentials: UserLogin,
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Login with JSON body (alternative to form data).""" """Login with JSON body (alternative to form data)."""
result = await db.execute(select(User).where(User.email == credentials.email)) result = await db.execute(select(User).where(User.email == credentials.email))
@@ -304,7 +304,7 @@ async def login_json(
async def refresh_token( async def refresh_token(
request: Request, request: Request,
payload: Annotated[dict, Depends(get_refresh_token_payload)], payload: Annotated[dict, Depends(get_refresh_token_payload)],
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Refresh access token using refresh token (rotation: old token is revoked).""" """Refresh access token using refresh token (rotation: old token is revoked)."""
user_id = payload.get("sub") user_id = payload.get("sub")
@@ -368,7 +368,7 @@ async def get_me(
async def update_me( async def update_me(
data: UserUpdate, data: UserUpdate,
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Update current user's profile (name, email).""" """Update current user's profile (name, email)."""
update_fields = data.model_fields_set - {"current_password"} update_fields = data.model_fields_set - {"current_password"}
@@ -415,7 +415,7 @@ async def update_me(
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
payload: Annotated[dict, Depends(get_refresh_token_payload)], payload: Annotated[dict, Depends(get_refresh_token_payload)],
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Logout user by revoking the refresh token.""" """Logout user by revoking the refresh token."""
jti = payload.get("jti") jti = payload.get("jti")
@@ -438,7 +438,7 @@ async def change_password(
request: Request, request: Request,
data: ChangePasswordRequest, data: ChangePasswordRequest,
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Change the current user's password.""" """Change the current user's password."""
if not verify_password(data.current_password, current_user.password_hash): if not verify_password(data.current_password, current_user.password_hash):
@@ -478,7 +478,7 @@ async def change_password(
async def forgot_password( async def forgot_password(
request: Request, request: Request,
data: ForgotPasswordRequest, data: ForgotPasswordRequest,
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Request a password reset email. Always returns success (anti-enumeration).""" """Request a password reset email. Always returns success (anti-enumeration)."""
result = await db.execute(select(User).where(User.email == data.email)) result = await db.execute(select(User).where(User.email == data.email))
@@ -513,7 +513,7 @@ async def forgot_password(
@router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse) @router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse)
async def verify_reset_token( async def verify_reset_token(
data: VerifyResetTokenRequest, data: VerifyResetTokenRequest,
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Verify a password reset token is valid.""" """Verify a password reset token is valid."""
payload = decode_token(data.token) payload = decode_token(data.token)
@@ -544,7 +544,7 @@ async def verify_reset_token(
async def reset_password( async def reset_password(
request: Request, request: Request,
data: ResetPasswordRequest, data: ResetPasswordRequest,
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Reset password using a valid reset token.""" """Reset password using a valid reset token."""
payload = decode_token(data.token) payload = decode_token(data.token)
@@ -611,7 +611,7 @@ async def reset_password(
@router.get("/email/verification-status") @router.get("/email/verification-status")
async def get_verification_status( async def get_verification_status(
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Check if email verification is enabled on the platform.""" """Check if email verification is enabled on the platform."""
enabled = await SettingsManager.get("email_verification_enabled", db, default=True) enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
@@ -623,7 +623,7 @@ async def get_verification_status(
async def send_verification_email( async def send_verification_email(
request: Request, request: Request,
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Send an email verification link to the current user.""" """Send an email verification link to the current user."""
verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True) verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
@@ -662,7 +662,7 @@ async def send_verification_email(
@router.post("/email/verify") @router.post("/email/verify")
async def verify_email( async def verify_email(
data: dict, data: dict,
db: Annotated[AsyncSession, Depends(get_db)] db: Annotated[AsyncSession, Depends(get_admin_db)]
): ):
"""Verify an email using a token. Public endpoint.""" """Verify an email using a token. Public endpoint."""
token = data.get("token") token = data.get("token")

View File

@@ -0,0 +1,120 @@
"""Device types API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.schemas.device_type import (
DeviceTypeCreate,
DeviceTypeUpdate,
DeviceTypeResponse,
)
from app.core.service_account import PLATFORM_ACCOUNT_ID
router = APIRouter(prefix="/device-types", tags=["device-types"])
@router.get("/", response_model=list[DeviceTypeResponse])
async def list_device_types(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[DeviceTypeResponse]:
stmt = (
select(DeviceType)
.where(
or_(
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
DeviceType.account_id == current_user.account_id,
)
)
.order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label)
)
result = await db.execute(stmt)
rows = result.scalars().all()
return [DeviceTypeResponse.model_validate(r) for r in rows]
@router.post("/", response_model=DeviceTypeResponse, status_code=201)
async def create_device_type(
data: DeviceTypeCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.account_id == current_user.account_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your account")
system_existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
)
)
if system_existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' conflicts with a system type")
device_type = DeviceType(
slug=data.slug,
label=data.label,
category=data.category,
is_system=False,
account_id=current_user.account_id,
sort_order=data.sort_order,
)
db.add(device_type)
await db.commit()
await db.refresh(device_type)
return DeviceTypeResponse.model_validate(device_type)
@router.put("/{device_type_id}", response_model=DeviceTypeResponse)
async def update_device_type(
device_type_id: UUID,
data: DeviceTypeUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
device_type = await db.get(DeviceType, device_type_id)
if not device_type:
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot modify system device types")
if device_type.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Device type not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(device_type, field, value)
await db.commit()
await db.refresh(device_type)
return DeviceTypeResponse.model_validate(device_type)
@router.delete("/{device_type_id}", status_code=204)
async def delete_device_type(
device_type_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
device_type = await db.get(DeviceType, device_type_id)
if not device_type:
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot delete system device types")
if device_type.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Device type not found")
await db.delete(device_type)
await db.commit()

View File

@@ -1,6 +1,7 @@
"""PSA integration endpoints — connection CRUD and test.""" """PSA integration endpoints — connection CRUD and test."""
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated from typing import Annotated
from uuid import UUID from uuid import UUID
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete from sqlalchemy import delete
logger = logging.getLogger(__name__)
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin 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.core.database import get_db
from app.models.psa_connection import PsaConnection from app.models.psa_connection import PsaConnection
@@ -27,8 +30,20 @@ from app.schemas.psa_connection import (
PsaMemberMappingSaveRequest, PsaMemberMappingSaveRequest,
PsaMemberResponse, PsaMemberResponse,
AutoMatchResult, AutoMatchResult,
PSABoardResponse,
) )
from app.core.config import settings from app.core.config import settings
from app.schemas.psa_tickets import (
PSAResourceSchema,
PSATicketCreatedSchema,
PSATicketStatusUpdateSchema,
TicketCreatePayloadSchema,
PSAPrioritySchema,
TicketListResponseSchema,
AiParseRequestSchema,
AiParseResponseSchema,
)
import app.services.ticket_service as ticket_svc
from app.services.psa.encryption import ( from app.services.psa.encryption import (
decrypt_credentials, decrypt_credentials,
encrypt_credentials, encrypt_credentials,
@@ -345,16 +360,12 @@ async def update_flowpilot_settings(
# ── ticket / status / company endpoints ────────────────────────── # ── ticket / status / company endpoints ──────────────────────────
@router.get("/tickets/search", response_model=list[PSATicketSearchResult]) @router.get("/boards", response_model=list[PSABoardResponse])
async def search_tickets( async def list_boards(
current_user: Annotated[User, Depends(require_engineer_or_admin)], current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
query: str = "",
board_id: int | None = None,
status_id: int | None = None,
include_closed: bool = False,
): ):
"""Search ConnectWise tickets.""" """List PSA service boards."""
if not current_user.account_id: if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account") raise HTTPException(status_code=400, detail="User has no account")
@@ -363,25 +374,319 @@ async def search_tickets(
try: try:
provider = await get_provider_for_account(current_user.account_id, db) provider = await get_provider_for_account(current_user.account_id, db)
tickets = await provider.search_tickets( boards = await provider.list_boards()
query, board_id=board_id, status_id=status_id, include_closed=include_closed return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
except PSAError as e:
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
logger.warning("list_boards failed: %s", e)
return []
@router.get("/tickets/search", response_model=TicketListResponseSchema)
async def search_tickets(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
query: str = "",
board_id: int | None = None,
status_id: int | None = None,
include_closed: bool = False,
assigned_to_me: bool = False,
unassigned: bool = False,
board_ids: str = "",
priority: str | None = None,
company_id: int | None = None,
page: int = 1,
page_size: int = 25,
):
"""Search ConnectWise tickets — returns paginated TicketListResponse."""
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
member_identifier: str | None = None
if assigned_to_me:
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
) )
return [ )
conn = conn_result.scalar_one_or_none()
if conn:
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == conn.id,
PsaMemberMapping.user_id == current_user.id,
)
)
mapping = mapping_result.scalar_one_or_none()
if not mapping:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
try:
_provider = await get_provider_for_account(current_user.account_id, db)
cw_members = await _provider.list_members()
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
if matched:
member_identifier = matched.identifier
else:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
except PSAError:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
parsed_board_ids: list[int] = []
if board_ids:
try:
parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()]
except ValueError:
raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers")
try:
provider = await get_provider_for_account(current_user.account_id, db)
result = await provider.search_tickets(
query,
board_id=board_id,
status_id=status_id,
include_closed=include_closed,
member_identifier=member_identifier,
unassigned=unassigned,
board_ids=parsed_board_ids,
company_id=company_id,
page=page,
page_size=page_size,
)
items = [
PSATicketSearchResult( PSATicketSearchResult(
id=t.id, id=t.id,
summary=t.summary, summary=t.summary,
company_name=t.company_name, company_name=t.company_name,
company_id=t.company_id,
board_name=t.board_name, board_name=t.board_name,
board_id=t.board_id,
status_name=t.status_name, status_name=t.status_name,
status_id=t.status_id,
priority_name=t.priority_name, priority_name=t.priority_name,
priority_id=t.priority_id,
closed=t.closed, closed=t.closed,
) )
for t in tickets for t in result.items
] ]
return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size}
except PSAError as e: except PSAError as e:
raise HTTPException(status_code=502, detail=str(e)) raise HTTPException(status_code=502, detail=str(e))
@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201)
async def create_ticket(
data: TicketCreatePayloadSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a new PSA ticket."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
from app.services.psa.types import TicketCreatePayload
try:
return await ticket_svc.create_ticket(
current_user.account_id,
TicketCreatePayload(**data.model_dump()),
db,
)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema)
async def ai_parse_ticket(
data: AiParseRequestSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Parse natural language into a ticket pre-fill payload using Claude."""
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
import anthropic
import json
# Fetch boards + members for context (both cached)
boards = []
members = []
try:
provider = await get_provider_for_account(current_user.account_id, db)
boards = await provider.list_boards()
members = await provider.list_members()
except PSAError:
pass
boards_list = [{"id": b.id, "name": b.name} for b in boards]
members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members]
system_prompt = """You are a ticket triage assistant for an MSP help desk.
Extract structured ticket information from the engineer's natural language description.
Return ONLY valid JSON matching this exact schema — no other text:
{
"summary": "short one-line ticket title or null",
"board_id": "integer matching one of the provided boards or null",
"priority_name": "one of: Critical, High, Medium, Low, or null",
"description": "expanded description or null",
"assignee_identifier": "member identifier string from the provided members list or null",
"warnings": ["list of strings explaining what could not be resolved"]
}"""
user_msg = f"""Available boards: {json.dumps(boards_list)}
Available members: {json.dumps(members_list[:50])}
Engineer's description: {data.prompt}"""
missing_fields: list[str] = []
warnings: list[str] = []
response_data = AiParseResponseSchema()
try:
client = anthropic.AsyncAnthropic(
api_key=settings.ANTHROPIC_API_KEY,
max_retries=1,
)
msg = await client.messages.create(
model=settings.get_model_for_action("default"),
max_tokens=512,
system=system_prompt,
messages=[{"role": "user", "content": user_msg}],
)
raw = msg.content[0].text.strip()
# Strip markdown fences if present
if raw.startswith("```"):
import re
raw = re.sub(r'^```(?:json)?\s*', '', raw)
raw = re.sub(r'\s*```$', '', raw.strip())
parsed = json.loads(raw)
response_data.summary = parsed.get("summary")
response_data.description = parsed.get("description")
warnings = parsed.get("warnings", [])
# Resolve board_id
if parsed.get("board_id"):
board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None)
if board_match:
response_data.board_id = board_match.id
else:
missing_fields.append("board_id")
warnings.append(f"Board ID {parsed['board_id']} not found")
else:
missing_fields.append("board_id")
# Resolve assignee
if parsed.get("assignee_identifier"):
member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None)
if member:
response_data.assigned_member_id = int(member.id)
else:
warnings.append(f"Member '{parsed['assignee_identifier']}' not found")
# Priority/status/company always need manual selection
missing_fields.extend(["status_id", "priority_id", "company_id"])
except Exception as e:
logger.warning("AI parse failed: %s", e)
missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"]
warnings = ["AI parsing failed — please fill in manually"]
response_data.missing_fields = missing_fields
response_data.warnings = warnings
return response_data
@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema)
async def update_ticket_status_endpoint(
ticket_id: int,
status_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a ticket's status."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema])
async def list_ticket_resources(
ticket_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.list_resources(current_user.account_id, ticket_id, db)
except PSAError as e:
# Resources are optional display data — degrade gracefully rather than surfacing a toast
logger.warning("list_resources(%s) failed: %s", ticket_id, e)
return []
@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201)
async def add_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204)
async def remove_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/priorities", response_model=list[PSAPrioritySchema])
async def list_priorities(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List PSA priority levels for ticket creation form."""
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)
raw = await provider.list_priorities()
return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")]
except PSAError as e:
logger.warning("list_priorities failed: %s", e)
return []
@router.get("/tickets/{ticket_id}/context") @router.get("/tickets/{ticket_id}/context")
async def get_ticket_context( async def get_ticket_context(
ticket_id: int, ticket_id: int,
@@ -483,7 +788,30 @@ async def get_ticket_statuses(
except PSANotFoundError: except PSANotFoundError:
raise HTTPException(status_code=404, detail="Ticket not found") raise HTTPException(status_code=404, detail="Ticket not found")
except PSAError as e: except PSAError as e:
raise HTTPException(status_code=502, detail=str(e)) logger.warning("get_ticket_statuses(%s) failed: %s", ticket_id, e)
return []
@router.get("/boards/{board_id}/statuses", response_model=list[PSATicketStatusItem])
async def get_board_statuses(
board_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get available statuses for a service board directly (no ticket lookup required)."""
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)
statuses = await provider.get_ticket_statuses(board_id)
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
except PSAError as e:
logger.warning("get_board_statuses(%s) failed: %s", board_id, e)
return []
# ── member mapping endpoints ───────────────────────────────────────── # ── member mapping endpoints ─────────────────────────────────────────
@@ -491,7 +819,7 @@ async def get_ticket_statuses(
@router.get("/members", response_model=list[PsaMemberResponse]) @router.get("/members", response_model=list[PsaMemberResponse])
async def list_members( async def list_members(
current_user: Annotated[User, Depends(require_account_owner)], current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
): ):
"""List CW members (from CW API).""" """List CW members (from CW API)."""
@@ -509,7 +837,9 @@ async def list_members(
for m in members for m in members
] ]
except PSAError as e: except PSAError as e:
raise HTTPException(status_code=502, detail=str(e)) # Members are optional display data — degrade gracefully
logger.warning("list_members failed: %s", e)
return []
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse]) @router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
@@ -517,31 +847,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:
response.append(PsaMemberMappingResponse(
id=str(m.id),
user_id=str(m.user_id),
user_email=user.email, user_email=user.email,
user_name=user.name, user_name=user.name,
external_member_id=m.external_member_id, external_member_id=m.external_member_id if m else None,
external_member_name=m.external_member_name, external_member_name=m.external_member_name if m else None,
matched_by=m.matched_by, matched_by=m.matched_by if m else None,
)) )
return response for user in users
]
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse]) @router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
@@ -564,6 +900,7 @@ async def save_member_mappings(
for m in mappings: for m in mappings:
mapping = PsaMemberMapping( mapping = PsaMemberMapping(
psa_connection_id=conn.id, psa_connection_id=conn.id,
account_id=current_user.account_id,
user_id=UUID(m.user_id), user_id=UUID(m.user_id),
external_member_id=m.external_member_id, external_member_id=m.external_member_id,
external_member_name=m.external_member_name, external_member_name=m.external_member_name,
@@ -624,6 +961,7 @@ async def auto_match_members(
if not existing.scalar_one_or_none(): if not existing.scalar_one_or_none():
mapping = PsaMemberMapping( mapping = PsaMemberMapping(
psa_connection_id=conn.id, psa_connection_id=conn.id,
account_id=current_user.account_id,
user_id=user.id, user_id=user.id,
external_member_id=cw_member.id, external_member_id=cw_member.id,
external_member_name=cw_member.name, external_member_name=cw_member.name,

View File

@@ -69,6 +69,7 @@ async def create_schedule(
schedule = MaintenanceSchedule( schedule = MaintenanceSchedule(
tree_id=data.tree_id, tree_id=data.tree_id,
account_id=current_user.account_id,
created_by=current_user.id, created_by=current_user.id,
cron_expression=data.cron_expression, cron_expression=data.cron_expression,
timezone=data.timezone, timezone=data.timezone,

View File

@@ -0,0 +1,362 @@
"""Network diagrams API endpoints."""
import base64
import logging
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.models.network_diagram import NetworkDiagram
from app.core.service_account import PLATFORM_ACCOUNT_ID
from app.schemas.network_diagram import (
NetworkDiagramCreate,
NetworkDiagramUpdate,
NetworkDiagramResponse,
NetworkDiagramListItem,
AIGenerateRequest,
AIGenerateResponse,
DiagramImportRequest,
DiagramImportResponse,
DiagramExportResponse,
DiagramNode,
DiagramEdge,
)
from app.services import network_diagram_ai_service, storage_service
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
_SLUG_CATEGORY: dict[str, str] = {
"router": "network", "switch": "network", "access-point": "network", "load-balancer": "network",
"firewall": "security", "badge-reader": "security",
"server": "compute", "vm": "compute", "container": "compute",
"nas": "storage", "san": "storage", "cloud-storage": "storage",
"cloud": "cloud", "aws": "cloud", "azure": "cloud", "gcp": "cloud", "isp": "cloud",
"workstation": "endpoint", "laptop": "endpoint", "tablet": "endpoint",
"phone": "endpoint", "printer": "endpoint",
"ups": "infrastructure", "pdu": "infrastructure", "rack": "infrastructure",
"patch-panel": "infrastructure", "camera": "infrastructure",
"nvr": "infrastructure", "iot": "infrastructure",
}
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
async def _get_diagram_or_404(
diagram_id: UUID,
account_id: UUID,
db: AsyncSession,
) -> NetworkDiagram:
diagram = await db.get(NetworkDiagram, diagram_id)
if not diagram or diagram.account_id != account_id or diagram.is_archived:
raise HTTPException(status_code=404, detail="Diagram not found")
return diagram
def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse:
return NetworkDiagramResponse.model_validate(diagram)
def _diagram_to_list_item(
diagram: NetworkDiagram,
custom_slug_category: dict[str, str] | None = None,
) -> NetworkDiagramListItem:
nodes = diagram.nodes if isinstance(diagram.nodes, list) else []
slug_to_cat = {**_SLUG_CATEGORY, **(custom_slug_category or {})}
category_counts: dict[str, int] = {}
for node in nodes:
slug = node.get("type", "") if isinstance(node, dict) else ""
cat = slug_to_cat.get(slug, "other")
category_counts[cat] = category_counts.get(cat, 0) + 1
return NetworkDiagramListItem(
id=diagram.id,
name=diagram.name,
client_name=diagram.client_name,
description=diagram.description,
node_count=len(nodes),
category_counts=category_counts,
thumbnail_url=diagram.thumbnail_url,
created_by=diagram.created_by,
created_at=diagram.created_at,
updated_at=diagram.updated_at,
)
async def _get_available_slugs(account_id: UUID, db: AsyncSession) -> set[str]:
stmt = select(DeviceType.slug).where(
or_(
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
DeviceType.account_id == account_id,
)
)
result = await db.execute(stmt)
return {row[0] for row in result.all()}
@router.get("/clients", response_model=list[str])
async def list_client_names(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[str]:
stmt = (
select(NetworkDiagram.client_name)
.where(
NetworkDiagram.account_id == current_user.account_id,
NetworkDiagram.is_archived.is_(False),
NetworkDiagram.client_name.isnot(None),
NetworkDiagram.client_name != "",
)
.distinct()
.order_by(NetworkDiagram.client_name)
)
result = await db.execute(stmt)
return [row[0] for row in result.all()]
@router.get("/", response_model=list[NetworkDiagramListItem])
async def list_diagrams(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
client_name: str | None = Query(default=None),
search: str | None = Query(default=None),
) -> list[NetworkDiagramListItem]:
stmt = (
select(NetworkDiagram)
.where(
NetworkDiagram.account_id == current_user.account_id,
NetworkDiagram.is_archived.is_(False),
)
.order_by(NetworkDiagram.updated_at.desc())
)
if client_name:
stmt = stmt.where(NetworkDiagram.client_name == client_name)
if search:
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
search_filter = f"%{escaped}%"
stmt = stmt.where(
or_(
NetworkDiagram.name.ilike(search_filter),
NetworkDiagram.client_name.ilike(search_filter),
)
)
# Single query for custom device types so category_counts is accurate
dt_stmt = select(DeviceType.slug, DeviceType.category).where(
DeviceType.is_system.is_(False),
DeviceType.account_id == current_user.account_id,
)
dt_result = await db.execute(dt_stmt)
custom_slug_category = {row[0]: row[1] for row in dt_result.all()}
result = await db.execute(stmt)
rows = result.scalars().all()
return [_diagram_to_list_item(r, custom_slug_category) for r in rows]
@router.post("/", response_model=NetworkDiagramResponse, status_code=201)
async def create_diagram(
data: NetworkDiagramCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = NetworkDiagram(
account_id=current_user.account_id,
name=data.name,
client_name=data.client_name,
asset_name=data.asset_name,
description=data.description,
nodes=[n.model_dump() for n in data.nodes],
edges=[e.model_dump() for e in data.edges],
created_by=current_user.id,
)
db.add(diagram)
await db.commit()
await db.refresh(diagram)
return _diagram_to_response(diagram)
@router.get("/{diagram_id}", response_model=NetworkDiagramResponse)
async def get_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
return _diagram_to_response(diagram)
@router.put("/{diagram_id}", response_model=NetworkDiagramResponse)
async def update_diagram(
diagram_id: UUID,
data: NetworkDiagramUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
update_data = data.model_dump(exclude_unset=True)
if "nodes" in update_data and update_data["nodes"] is not None:
update_data["nodes"] = [n.model_dump() if hasattr(n, "model_dump") else n for n in update_data["nodes"]]
if "edges" in update_data and update_data["edges"] is not None:
update_data["edges"] = [e.model_dump() if hasattr(e, "model_dump") else e for e in update_data["edges"]]
for field, value in update_data.items():
setattr(diagram, field, value)
diagram.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(diagram)
return _diagram_to_response(diagram)
@router.delete("/{diagram_id}", status_code=204)
async def archive_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
diagram.is_archived = True
diagram.updated_at = datetime.now(timezone.utc)
await db.commit()
@router.post("/{diagram_id}/duplicate", response_model=NetworkDiagramResponse, status_code=201)
async def duplicate_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
source = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
copy = NetworkDiagram(
account_id=current_user.account_id,
name=f"Copy of {source.name}",
client_name=source.client_name,
asset_name=source.asset_name,
description=source.description,
nodes=source.nodes,
edges=source.edges,
created_by=current_user.id,
)
db.add(copy)
await db.commit()
await db.refresh(copy)
return _diagram_to_response(copy)
@router.get("/{diagram_id}/export", response_model=DiagramExportResponse)
async def export_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramExportResponse:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
nodes = [DiagramNode(**n) for n in (diagram.nodes or [])]
edges = [DiagramEdge(**e) for e in (diagram.edges or [])]
return DiagramExportResponse(
schemaVersion=1,
name=diagram.name,
client_name=diagram.client_name,
description=diagram.description,
nodes=nodes,
edges=edges,
exportedAt=datetime.now(timezone.utc).isoformat(),
)
@router.post("/import", response_model=DiagramImportResponse, status_code=201)
async def import_diagram(
data: DiagramImportRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramImportResponse:
available_slugs = await _get_available_slugs(current_user.account_id, db)
warnings: list[str] = []
for node in data.nodes:
if node.type not in available_slugs:
warnings.append(f"Unknown device type '{node.type}' — will render with default icon")
diagram = NetworkDiagram(
account_id=current_user.account_id,
name=data.name,
client_name=data.client_name,
description=data.description,
nodes=[n.model_dump() for n in data.nodes],
edges=[e.model_dump() for e in data.edges],
created_by=current_user.id,
)
db.add(diagram)
await db.commit()
await db.refresh(diagram)
return DiagramImportResponse(
diagram=_diagram_to_response(diagram),
warnings=warnings,
)
class ThumbnailUploadRequest(BaseModel):
data_url: str # base64 PNG data URL: "data:image/png;base64,..."
@router.post("/{diagram_id}/thumbnail", status_code=204)
async def upload_thumbnail(
diagram_id: UUID,
body: ThumbnailUploadRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
try:
header, encoded = body.data_url.split(",", 1)
except ValueError:
raise HTTPException(status_code=422, detail="Invalid data URL format")
image_bytes = base64.b64decode(encoded)
storage_key = await storage_service.upload_file(
file_data=image_bytes,
filename=f"thumbnail-{diagram_id}.png",
content_type="image/png",
account_id=str(current_user.account_id),
)
presigned_url = storage_service.get_presigned_url(storage_key)
diagram.thumbnail_url = presigned_url
await db.commit()
@router.post("/ai-generate", response_model=AIGenerateResponse)
async def ai_generate_diagram(
data: AIGenerateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> AIGenerateResponse:
available_slugs_set = await _get_available_slugs(current_user.account_id, db)
available_slugs = list(available_slugs_set)
existing_node_ids: list[str] | None = None
if data.mode == "merge" and data.existingBounds:
existing_node_ids = []
try:
return await network_diagram_ai_service.generate_diagram(
request=data,
available_slugs=available_slugs,
existing_node_ids=existing_node_ids,
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception:
logger.exception("AI diagram generation failed")
raise HTTPException(status_code=500, detail="Diagram generation failed")

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user from app.api.deps import get_current_active_user
from app.core.database import get_db from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.assistant_chat import AssistantChat from app.models.assistant_chat import AssistantChat
from app.models.psa_connection import PsaConnection from app.models.psa_connection import PsaConnection
from app.models.session import Session from app.models.session import Session
@@ -98,7 +99,7 @@ async def get_onboarding_status(
@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus) @router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
async def dismiss_onboarding( async def dismiss_onboarding(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
) -> OnboardingStatus: ) -> OnboardingStatus:
"""Dismiss the onboarding checklist for the current user.""" """Dismiss the onboarding checklist for the current user."""

View File

@@ -91,6 +91,7 @@ async def submit_step_feedback(
new_rating = StepRating( new_rating = StepRating(
step_id=step_id, step_id=step_id,
user_id=current_user.id, user_id=current_user.id,
account_id=current_user.account_id,
session_id=session_uuid, session_id=session_uuid,
was_helpful=data.was_helpful, was_helpful=data.was_helpful,
# rating is nullable now — thumbs-only mode # rating is nullable now — thumbs-only mode

View File

@@ -85,6 +85,7 @@ async def create_session(
session = await script_builder_service.create_session( session = await script_builder_service.create_session(
db=db, db=db,
user_id=current_user.id, user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id, team_id=current_user.team_id,
language=data.language, language=data.language,
) )

View File

@@ -196,6 +196,7 @@ async def start_session(
new_session = Session( new_session = Session(
tree_id=tree.id, tree_id=tree.id,
user_id=current_user.id, user_id=current_user.id,
account_id=current_user.account_id,
tree_snapshot=tree_snapshot, tree_snapshot=tree_snapshot,
path_taken=[], path_taken=[],
decisions=[], decisions=[],
@@ -693,6 +694,7 @@ async def prepare_session(
new_session = Session( new_session = Session(
tree_id=tree.id, tree_id=tree.id,
user_id=data.assigned_to_id or current_user.id, user_id=data.assigned_to_id or current_user.id,
account_id=current_user.account_id,
tree_snapshot=tree_snapshot, tree_snapshot=tree_snapshot,
path_taken=[], path_taken=[],
decisions=[], decisions=[],
@@ -770,6 +772,7 @@ async def batch_launch_sessions(
session = Session( session = Session(
tree_id=tree.id, tree_id=tree.id,
user_id=current_user.id, user_id=current_user.id,
account_id=current_user.account_id,
tree_snapshot=tree_snapshot, tree_snapshot=tree_snapshot,
path_taken=[], path_taken=[],
decisions=[], decisions=[],
@@ -1102,6 +1105,7 @@ async def psa_post_to_ticket(
# Log to audit trail # Log to audit trail
log_entry = PsaPostLog( log_entry = PsaPostLog(
session_id=session.id, session_id=session.id,
account_id=session.account_id,
psa_connection_id=psa_connection.id if psa_connection else None, psa_connection_id=psa_connection.id if psa_connection else None,
ticket_id=session.psa_ticket_id, ticket_id=session.psa_ticket_id,
note_type=data.note_type, note_type=data.note_type,

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import joinedload
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app.core.database import get_db from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.session import Session from app.models.session import Session
from app.models.session_share import SessionShare, SessionShareView from app.models.session_share import SessionShare, SessionShareView
from app.models.user import User from app.models.user import User
@@ -210,7 +211,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
async def access_share( async def access_share(
share_token: str, share_token: str,
request: Request, request: Request,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_admin_db)],
): ):
"""Access a shared session via share token. """Access a shared session via share token.

View File

@@ -460,6 +460,7 @@ async def rate_step(
rating = StepRating( rating = StepRating(
step_id=step_id, step_id=step_id,
user_id=current_user.id, user_id=current_user.id,
account_id=current_user.account_id,
rating=rating_data.rating, rating=rating_data.rating,
was_helpful=rating_data.was_helpful, was_helpful=rating_data.was_helpful,
review_text=rating_data.review_text, review_text=rating_data.review_text,

View File

@@ -103,6 +103,7 @@ async def create_supporting_data(
item = SessionSupportingData( item = SessionSupportingData(
session_id=session_id, session_id=session_id,
account_id=session.account_id,
label=data.label, label=data.label,
data_type=data.data_type, data_type=data.data_type,
content=data.content, content=data.content,

View File

@@ -18,12 +18,10 @@ async def list_target_lists(
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
): ):
"""List all target lists for the current user's team.""" """List all target lists for the current user's account."""
if not current_user.team_id:
return []
result = await db.execute( result = await db.execute(
select(TargetList) select(TargetList)
.where(TargetList.team_id == current_user.team_id) .where(TargetList.account_id == current_user.account_id)
.order_by(TargetList.name) .order_by(TargetList.name)
) )
return result.scalars().all() return result.scalars().all()
@@ -36,11 +34,9 @@ async def create_target_list(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin), _: None = Depends(require_engineer_or_admin),
): ):
"""Create a new target list for the current team.""" """Create a new target list for the current account."""
if not current_user.team_id:
raise HTTPException(status_code=400, detail="User must belong to a team")
target_list = TargetList( target_list = TargetList(
team_id=current_user.team_id, account_id=current_user.account_id,
created_by=current_user.id, created_by=current_user.id,
name=data.name, name=data.name,
description=data.description, description=data.description,
@@ -61,7 +57,7 @@ async def get_target_list(
result = await db.execute( result = await db.execute(
select(TargetList).where( select(TargetList).where(
TargetList.id == list_id, TargetList.id == list_id,
TargetList.team_id == current_user.team_id, TargetList.account_id == current_user.account_id,
) )
) )
target_list = result.scalar_one_or_none() target_list = result.scalar_one_or_none()
@@ -81,7 +77,7 @@ async def update_target_list(
result = await db.execute( result = await db.execute(
select(TargetList).where( select(TargetList).where(
TargetList.id == list_id, TargetList.id == list_id,
TargetList.team_id == current_user.team_id, TargetList.account_id == current_user.account_id,
) )
) )
target_list = result.scalar_one_or_none() target_list = result.scalar_one_or_none()
@@ -91,7 +87,7 @@ async def update_target_list(
if "name" in update_fields and data.name is not None: if "name" in update_fields and data.name is not None:
target_list.name = data.name target_list.name = data.name
if "description" in update_fields: if "description" in update_fields:
target_list.description = data.description # allow setting to None target_list.description = data.description
if "targets" in update_fields and data.targets is not None: if "targets" in update_fields and data.targets is not None:
target_list.targets = [t.model_dump() for t in data.targets] target_list.targets = [t.model_dump() for t in data.targets]
await db.commit() await db.commit()
@@ -109,7 +105,7 @@ async def delete_target_list(
result = await db.execute( result = await db.execute(
select(TargetList).where( select(TargetList).where(
TargetList.id == list_id, TargetList.id == list_id,
TargetList.team_id == current_user.team_id, TargetList.account_id == current_user.account_id,
) )
) )
target_list = result.scalar_one_or_none() target_list = result.scalar_one_or_none()

View File

@@ -1048,6 +1048,7 @@ async def create_tree_share(
# Create share # Create share
tree_share = TreeShare( tree_share = TreeShare(
tree_id=tree.id, tree_id=tree.id,
account_id=tree.account_id, # share belongs to the tree's tenant, not the actor
share_token=share_token, share_token=share_token,
created_by=current_user.id, created_by=current_user.id,
allow_forking=share_data.allow_forking, allow_forking=share_data.allow_forking,

View File

@@ -24,6 +24,7 @@ from app.api.endpoints import (
branding, branding,
categories, categories,
copilot, copilot,
device_types,
feedback, feedback,
flow_proposals, flow_proposals,
flowpilot_analytics, flowpilot_analytics,
@@ -32,6 +33,7 @@ from app.api.endpoints import (
invite, invite,
kb_accelerator, kb_accelerator,
maintenance_schedules, maintenance_schedules,
network_diagrams,
notifications, notifications,
onboarding, onboarding,
public_templates, public_templates,
@@ -93,7 +95,6 @@ api_router.include_router(admin_settings.router)
api_router.include_router(admin_categories.router) api_router.include_router(admin_categories.router)
api_router.include_router(admin_survey.router) api_router.include_router(admin_survey.router)
api_router.include_router(admin_gallery.router) api_router.include_router(admin_gallery.router)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# User-facing endpoints — tenant context required # User-facing endpoints — tenant context required
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -130,6 +131,7 @@ api_router.include_router(integrations.router, dependencies=_tenant_deps)
api_router.include_router(onboarding.router, dependencies=_tenant_deps) api_router.include_router(onboarding.router, dependencies=_tenant_deps)
api_router.include_router(branding.router, dependencies=_tenant_deps) api_router.include_router(branding.router, dependencies=_tenant_deps)
api_router.include_router(supporting_data.router, dependencies=_tenant_deps) api_router.include_router(supporting_data.router, dependencies=_tenant_deps)
api_router.include_router(network_diagrams.router, dependencies=_tenant_deps)
# session_handoffs queue router must come before ai_sessions to avoid conflict # session_handoffs queue router must come before ai_sessions to avoid conflict
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps) api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps) api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
@@ -142,3 +144,4 @@ api_router.include_router(script_builder.router, dependencies=_tenant_deps)
api_router.include_router(beta_feedback.router, dependencies=_tenant_deps) api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
api_router.include_router(session_branches.router, dependencies=_tenant_deps) api_router.include_router(session_branches.router, dependencies=_tenant_deps)
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps) api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
api_router.include_router(device_types.router, dependencies=_tenant_deps)

View File

@@ -2,8 +2,10 @@
""" """
Admin database engine — connects as resolutionflow_admin (BYPASSRLS). Admin database engine — connects as resolutionflow_admin (BYPASSRLS).
Use ONLY for /admin/* endpoints and internal tooling. Use ONLY where explicit application-level access control makes database-layer
Never use this engine from user-facing endpoints. tenant filtering unnecessary: /admin/* endpoints, internal tooling, and public
endpoints that enforce their own authorization before returning data (e.g.
share access via opaque token + visibility check).
""" """
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
@@ -25,7 +27,7 @@ _admin_session_factory = async_sessionmaker(
async def get_admin_db() -> AsyncGenerator[AsyncSession, None]: async def get_admin_db() -> AsyncGenerator[AsyncSession, None]:
"""Yield an admin DB session (BYPASSRLS). Use only on /admin/* endpoints.""" """Yield an admin DB session (BYPASSRLS). See module docstring for approved use cases."""
async with _admin_session_factory() as session: async with _admin_session_factory() as session:
try: try:
yield session yield session

View File

@@ -12,10 +12,19 @@ async def log_audit(
resource_type: str, resource_type: str,
resource_id: Optional[UUID] = None, resource_id: Optional[UUID] = None,
details: Optional[dict] = None, details: Optional[dict] = None,
account_id: Optional[UUID] = None,
) -> None: ) -> None:
"""Record an audit log entry. Does not commit — piggybacks on the caller's commit.""" """Record an audit log entry. Does not commit — piggybacks on the caller's commit."""
if account_id is None:
# Derive from the acting user's account as a fallback (one extra query).
from sqlalchemy import select
from app.models.user import User
result = await db.execute(select(User.account_id).where(User.id == user_id))
account_id = result.scalar_one()
entry = AuditLog( entry = AuditLog(
user_id=user_id, user_id=user_id,
account_id=account_id,
action=action, action=action,
resource_type=resource_type, resource_type=resource_type,
resource_id=resource_id, resource_id=resource_id,

View File

@@ -128,6 +128,7 @@ class Settings(BaseSettings):
"variable_inference": "fast", "variable_inference": "fast",
"kb_convert": "standard", "kb_convert": "standard",
"script_build": "standard", "script_build": "standard",
"network_diagram_generate": "standard",
} }
def get_model_for_action(self, action_type: str) -> str: def get_model_for_action(self, action_type: str) -> str:

View File

@@ -21,7 +21,7 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
"""Create batch sessions for a scheduled maintenance run.""" """Create batch sessions for a scheduled maintenance run."""
# Import all models first to ensure SQLAlchemy mapper relationships resolve # Import all models first to ensure SQLAlchemy mapper relationships resolve
import app.models # noqa: F401 import app.models # noqa: F401
from app.core.database import async_session_maker from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.maintenance_schedule import MaintenanceSchedule from app.models.maintenance_schedule import MaintenanceSchedule
from app.models.session import Session from app.models.session import Session
from app.models.target_list import TargetList from app.models.target_list import TargetList
@@ -118,7 +118,7 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
async def _cleanup_expired_ai_conversations() -> None: async def _cleanup_expired_ai_conversations() -> None:
"""Delete expired AI wizard conversations.""" """Delete expired AI wizard conversations."""
import app.models # noqa: F401 import app.models # noqa: F401
from app.core.database import async_session_maker from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.ai_conversation import AIConversation from app.models.ai_conversation import AIConversation
async with async_session_maker() as db: async with async_session_maker() as db:

View File

@@ -14,6 +14,8 @@ import logging
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.admin_database import _admin_session_factory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com" SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
@@ -52,13 +54,18 @@ async def _ensure_system_account(db: AsyncSession) -> uuid.UUID:
async def ensure_service_account(db: AsyncSession) -> uuid.UUID: async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
"""Ensure the ResolutionFlow service account exists and return its ID. """Ensure the ResolutionFlow service account exists and return its ID.
Idempotent — safe to call on every startup. Creates the account if it Idempotent — safe to call on every startup. This lookup must bypass RLS
does not exist. The account has no usable password and is_service_account=True because startup runs before any request-scoped tenant context exists and
so it can never log in via normal auth flows. the users table is tenant-isolated in Phase 4. The service account is
normally created by Alembic migration 1490781700bc; the runtime create path
remains as a self-healing fallback for environments that predate that seed.
""" """
_ = db # Retained for call-site compatibility in app lifespan startup.
from app.models.user import User from app.models.user import User
result = await db.execute( async with _admin_session_factory() as admin_db:
result = await admin_db.execute(
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL) select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
) )
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -66,10 +73,10 @@ async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
if user is not None: if user is not None:
if not user.is_service_account: if not user.is_service_account:
user.is_service_account = True user.is_service_account = True
await db.commit() await admin_db.commit()
return user.id return user.id
account_id = await _ensure_system_account(db) account_id = await _ensure_system_account(admin_db)
new_user = User( new_user = User(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -85,7 +92,7 @@ async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
account_id=account_id, account_id=account_id,
account_role="engineer", account_role="engineer",
) )
db.add(new_user) admin_db.add(new_user)
await db.commit() await admin_db.commit()
logger.info(f"[service_account] Created service account (id={new_user.id})") logger.info(f"[service_account] Created service account (id={new_user.id})")
return new_user.id return new_user.id

View File

@@ -25,7 +25,8 @@ if settings.SENTRY_DSN:
), ),
) )
from app.core.database import init_db, async_session_maker from app.core.database import init_db
from app.core.admin_database import _admin_session_factory as async_session_maker
from app.core.logging_config import setup_logging from app.core.logging_config import setup_logging
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
from app.core.security_headers import SecurityHeadersMiddleware from app.core.security_headers import SecurityHeadersMiddleware

View File

@@ -56,6 +56,8 @@ from .session_handoff import SessionHandoff
from .session_resolution_output import SessionResolutionOutput from .session_resolution_output import SessionResolutionOutput
from .template_tree import TemplateTree from .template_tree import TemplateTree
from .platform_step import PlatformStep from .platform_step import PlatformStep
from .device_type import DeviceType
from .network_diagram import NetworkDiagram
__all__ = [ __all__ = [
"User", "User",
@@ -126,4 +128,6 @@ __all__ = [
"SessionResolutionOutput", "SessionResolutionOutput",
"TemplateTree", "TemplateTree",
"PlatformStep", "PlatformStep",
"DeviceType",
"NetworkDiagram",
] ]

View File

@@ -21,6 +21,12 @@ class AuditLog(Base):
nullable=False, nullable=False,
index=True index=True
) )
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True) action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) resource_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column( resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(

View File

@@ -0,0 +1,47 @@
"""Device type model for network diagrams."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class DeviceType(Base):
"""A device type for network diagram nodes (platform or account-custom)."""
__tablename__ = "device_types"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
slug: Mapped[str] = mapped_column(
String(50), nullable=False,
comment="Unique identifier used in diagram node data",
)
label: Mapped[str] = mapped_column(
String(100), nullable=False,
comment="Display name",
)
category: Mapped[str] = mapped_column(
String(50), nullable=False,
comment="network, compute, storage, cloud, endpoint, infrastructure, security",
)
is_system: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
comment="True for built-in types that cannot be deleted",
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
comment="Platform account for system types, tenant account for custom types",
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
comment="Display order within category",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -0,0 +1,53 @@
"""Network diagram model."""
import uuid
from datetime import datetime, timezone
from typing import Any, TYPE_CHECKING
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class NetworkDiagram(Base):
"""A network topology diagram scoped to one account."""
__tablename__ = "network_diagrams"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
is_archived: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
creator: Mapped["User | None"] = relationship("User", foreign_keys=[created_by])

View File

@@ -8,7 +8,6 @@ from app.core.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.user import User from app.models.user import User
from app.models.team import Team
from app.models.account import Account from app.models.account import Account
@@ -18,10 +17,6 @@ class TargetList(Base):
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
) )
team_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"),
nullable=False, index=True
)
account_id: Mapped[uuid.UUID] = mapped_column( account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"), ForeignKey("accounts.id", ondelete="CASCADE"),

View File

@@ -25,6 +25,12 @@ class TreeShare(Base):
nullable=False, nullable=False,
index=True index=True
) )
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
share_token: Mapped[str] = mapped_column( share_token: Mapped[str] = mapped_column(
String(64), String(64),
unique=True, unique=True,

View File

@@ -20,6 +20,7 @@ from .psa_connection import (
PSATicketSearchResult, PSATicketStatusItem, PSATicketSearchResult, PSATicketStatusItem,
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse, PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult, PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
PSABoardResponse,
) )
__all__ = [ __all__ = [
@@ -50,4 +51,5 @@ __all__ = [
"PSATicketSearchResult", "PSATicketStatusItem", "PSATicketSearchResult", "PSATicketStatusItem",
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse", "PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult", "PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
"PSABoardResponse",
] ]

View File

@@ -28,6 +28,111 @@ class ActivityEntry(BaseModel):
from_attributes = True from_attributes = True
# --- Admin Accounts & People Search ---
class AdminUserListItem(BaseModel):
id: UUID
email: EmailStr
name: str
role: str
is_super_admin: bool = False
is_active: bool = True
account_id: Optional[UUID] = None
account_role: Optional[str] = None
account_name: Optional[str] = None
account_display_code: Optional[str] = None
created_at: datetime
last_login: Optional[datetime] = None
deleted_at: Optional[datetime] = None
class AdminUserListResponse(BaseModel):
items: list[AdminUserListItem]
total: int
page: int
per_page: int
class AdminAccountMember(BaseModel):
id: UUID
email: EmailStr
name: str
role: str
is_super_admin: bool = False
is_active: bool = True
account_role: Optional[str] = None
created_at: datetime
last_login: Optional[datetime] = None
deleted_at: Optional[datetime] = None
class AdminAccountOwnerSummary(BaseModel):
id: UUID
name: str
email: EmailStr
class AdminAccountSubscriptionSummary(BaseModel):
id: UUID
plan: str
status: str
billing_interval: Optional[str] = None
current_period_end: Optional[datetime] = None
cancel_at_period_end: bool = False
class AdminAccountUsageSummary(BaseModel):
tree_count: int = 0
session_count_this_month: int = 0
class AdminAccountInviteSummary(BaseModel):
id: UUID
email: EmailStr
role: str
expires_at: Optional[datetime] = None
created_at: datetime
used_at: Optional[datetime] = None
class AdminAccountListItem(BaseModel):
id: UUID
name: str
display_code: str
created_at: datetime
owner_id: Optional[UUID] = None
owner: Optional[AdminAccountOwnerSummary] = None
subscription: Optional[AdminAccountSubscriptionSummary] = None
usage: AdminAccountUsageSummary = Field(default_factory=AdminAccountUsageSummary)
member_count: int = 0
active_member_count: int = 0
pending_invite_count: int = 0
sso_enabled: bool = False
branding_company_name: Optional[str] = None
members: list[AdminAccountMember] = Field(default_factory=list)
class AdminAccountListResponse(BaseModel):
items: list[AdminAccountListItem]
total: int
page: int
per_page: int
class AdminAccountDetailResponse(AdminAccountListItem):
invites: list[AdminAccountInviteSummary] = Field(default_factory=list)
class AdminAccountCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
plan: Literal["free", "pro", "team"] = "free"
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")
class AdminAccountUpdate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
# --- Audit Logs --- # --- Audit Logs ---
class AuditLogEntry(BaseModel): class AuditLogEntry(BaseModel):
@@ -215,7 +320,7 @@ class AdminUserCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
account_mode: Literal["existing", "personal"] account_mode: Literal["existing", "personal"]
account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'") account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'")
account_role: Optional[Literal["engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'") account_role: Optional[Literal["owner", "admin", "engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
send_email: bool = True send_email: bool = True

View File

@@ -0,0 +1,37 @@
"""Pydantic schemas for device types."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class DeviceTypeCreate(BaseModel):
slug: str = Field(min_length=1, max_length=50, pattern=r"^[a-z0-9\-]+$")
label: str = Field(min_length=1, max_length=100)
category: str = Field(
min_length=1, max_length=50,
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
)
sort_order: int = Field(default=0, ge=0)
class DeviceTypeUpdate(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=100)
category: str | None = Field(
default=None, min_length=1, max_length=50,
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
)
sort_order: int | None = Field(default=None, ge=0)
class DeviceTypeResponse(BaseModel):
id: UUID
slug: str
label: str
category: str
is_system: bool
account_id: UUID
sort_order: int
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,145 @@
"""Pydantic schemas for network diagrams."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class Position(BaseModel):
x: float
y: float
class DeviceProperties(BaseModel):
hostname: str | None = None
ip: str | None = None
subnet: str | None = None
vendor: str | None = None
model: str | None = None
role: str | None = None
vlan: str | None = None
notes: str | None = None
status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$")
class NodeStyle(BaseModel):
width: float | None = None
height: float | None = None
class DiagramNode(BaseModel):
id: str
type: str
label: str
position: Position
properties: DeviceProperties = Field(default_factory=DeviceProperties)
nodeType: str | None = None
style: NodeStyle | None = None
parentId: str | None = None
class DiagramEdge(BaseModel):
id: str
source: str
target: str
label: str | None = None
connectionType: str = "ethernet"
speed: str | None = None
notes: str | None = None
routing: str | None = None
class NetworkDiagramCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
class NetworkDiagramUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] | None = None
edges: list[DiagramEdge] | None = None
class NetworkDiagramResponse(BaseModel):
id: UUID
account_id: UUID
name: str
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
thumbnail_url: str | None = None
is_archived: bool = False
created_by: UUID | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class NetworkDiagramListItem(BaseModel):
id: UUID
name: str
client_name: str | None = None
description: str | None = None
node_count: int = 0
category_counts: dict[str, int] = Field(default_factory=dict)
thumbnail_url: str | None = None
created_by: UUID | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ExistingBounds(BaseModel):
minX: float
maxX: float
minY: float
maxY: float
class AIGenerateRequest(BaseModel):
description: str = Field(min_length=1, max_length=5000)
client_name: str | None = None
mode: str = Field(default="replace", pattern=r"^(replace|merge)$")
existingBounds: ExistingBounds | None = None
class AIGenerateResponse(BaseModel):
nodes: list[DiagramNode]
edges: list[DiagramEdge]
suggestedName: str | None = None
notes: str | None = None
class DiagramImportRequest(BaseModel):
schemaVersion: int = Field(ge=1, le=1)
name: str = Field(min_length=1, max_length=255)
client_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
class DiagramImportResponse(BaseModel):
diagram: NetworkDiagramResponse
warnings: list[str] = Field(default_factory=list)
class DiagramExportResponse(BaseModel):
schemaVersion: int = 1
name: str
client_name: str | None = None
description: str | None = None
nodes: list[DiagramNode]
edges: list[DiagramEdge]
exportedAt: str

View File

@@ -53,9 +53,13 @@ class PSATicketSearchResult(BaseModel):
id: str id: str
summary: str summary: str
company_name: str | None = None company_name: str | None = None
company_id: str | None = None
board_name: str | None = None board_name: str | None = None
board_id: int | None = None
status_name: str | None = None status_name: str | None = None
status_id: int | None = None
priority_name: str | None = None priority_name: str | None = None
priority_id: int | None = None
closed: bool = False closed: bool = False
@@ -111,13 +115,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):
@@ -136,3 +140,8 @@ class PsaMemberResponse(BaseModel):
class AutoMatchResult(BaseModel): class AutoMatchResult(BaseModel):
matched: list[PsaMemberMappingResponse] matched: list[PsaMemberMappingResponse]
unmatched_users: int unmatched_users: int
class PSABoardResponse(BaseModel):
id: int
name: str

View File

@@ -0,0 +1,64 @@
"""Normalized DTOs for ticket management endpoints."""
from __future__ import annotations
from pydantic import BaseModel
class PSAResourceSchema(BaseModel):
member_id: int
member_name: str
member_identifier: str
is_rf_user: bool = False
class PSATicketCreatedSchema(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResourceSchema] = []
class PSATicketStatusUpdateSchema(BaseModel):
ticket_id: int
previous_status: str
new_status: str
class TicketCreatePayloadSchema(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class TicketListResponseSchema(BaseModel):
items: list = []
total: int = 0
page: int = 1
page_size: int = 25
class AiParseRequestSchema(BaseModel):
prompt: str
class AiParseResponseSchema(BaseModel):
summary: str | None = None
company_id: int | None = None
board_id: int | None = None
priority_id: int | None = None
status_id: int | None = None
assigned_member_id: int | None = None
description: str | None = None
missing_fields: list[str] = []
warnings: list[str] = []
class PSAPrioritySchema(BaseModel):
id: int
name: str

View File

@@ -23,7 +23,7 @@ class TargetListUpdate(BaseModel):
class TargetListResponse(BaseModel): class TargetListResponse(BaseModel):
id: UUID id: UUID
team_id: UUID account_id: UUID
created_by: Optional[UUID] created_by: Optional[UUID]
name: str name: str
description: Optional[str] description: Optional[str]

View File

@@ -68,4 +68,4 @@ class RoleUpdate(BaseModel):
class AccountRoleUpdate(BaseModel): class AccountRoleUpdate(BaseModel):
account_role: str = Field(..., pattern="^(engineer|viewer)$") account_role: str = Field(..., pattern="^(owner|admin|engineer|viewer)$")

View File

@@ -154,6 +154,23 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
- If a question is clearly outside your domain, say so briefly and redirect. - If a question is clearly outside your domain, say so briefly and redirect.
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so. - Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
## SPIN-OFF TICKET CREATION
When you identify a second distinct issue that is clearly separate from the primary topic \
of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \
Use this sparingly — only when the issue is genuinely independent, not for every tangential mention.
Format:
[ACTIONS]
[
{
"label": "Create ticket: <brief issue title>",
"command": "create_spin_off_ticket",
"description": "<one sentence description of the separate issue>"
}
]
[/ACTIONS]
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE ## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \ Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
No exceptions. Not even when forking. A response without at least one of these markers \ No exceptions. Not even when forking. A response without at least one of these markers \

View File

@@ -34,6 +34,7 @@ class BranchManager:
root = SessionBranch( root = SessionBranch(
id=uuid.uuid4(), id=uuid.uuid4(),
session_id=session_id, session_id=session_id,
account_id=session.account_id,
parent_branch_id=None, parent_branch_id=None,
branch_order=1, branch_order=1,
label="Root", label="Root",
@@ -68,9 +69,17 @@ class BranchManager:
"status": "untried", "status": "untried",
}) })
# Load session to get account_id for FK constraints
session_result = await self.db.execute(
select(AISession).where(AISession.id == session_id)
)
session = session_result.scalar_one_or_none()
account_id = session.account_id if session else None
fork_point = ForkPoint( fork_point = ForkPoint(
id=uuid.uuid4(), id=uuid.uuid4(),
session_id=session_id, session_id=session_id,
account_id=account_id,
parent_branch_id=parent_branch_id, parent_branch_id=parent_branch_id,
trigger_step_id=trigger_step_id, trigger_step_id=trigger_step_id,
fork_reason=fork_reason, fork_reason=fork_reason,
@@ -90,6 +99,7 @@ class BranchManager:
branch = SessionBranch( branch = SessionBranch(
id=branch_ids[i], id=branch_ids[i],
session_id=session_id, session_id=session_id,
account_id=account_id,
parent_branch_id=parent_branch_id, parent_branch_id=parent_branch_id,
fork_point_step_id=trigger_step_id, fork_point_step_id=trigger_step_id,
branch_order=i + 1, branch_order=i + 1,

View File

@@ -330,6 +330,7 @@ async def start_session(
# 7. Create first step # 7. Create first step
step = _create_step_from_parsed( step = _create_step_from_parsed(
session_id=session.id, session_id=session.id,
account_id=session.account_id,
step_order=0, step_order=0,
parsed=parsed, parsed=parsed,
input_tokens=input_tokens, input_tokens=input_tokens,
@@ -433,6 +434,7 @@ async def process_response(
# Create new step # Create new step
step = _create_step_from_parsed( step = _create_step_from_parsed(
session_id=session.id, session_id=session.id,
account_id=session.account_id,
step_order=session.step_count - 1, step_order=session.step_count - 1,
parsed=parsed, parsed=parsed,
input_tokens=input_tokens, input_tokens=input_tokens,
@@ -694,6 +696,7 @@ async def pickup_session(
briefing_step = AISessionStep( briefing_step = AISessionStep(
id=uuid.uuid4(), id=uuid.uuid4(),
session_id=session.id, session_id=session.id,
account_id=session.account_id,
branch_id=session.active_branch_id if session.is_branching else None, branch_id=session.active_branch_id if session.is_branching else None,
step_order=session.step_count, step_order=session.step_count,
step_type="action", step_type="action",
@@ -765,6 +768,7 @@ async def pickup_session(
next_step = _create_step_from_parsed( next_step = _create_step_from_parsed(
session_id=session.id, session_id=session.id,
account_id=session.account_id,
step_order=session.step_count - 1, step_order=session.step_count - 1,
parsed=parsed, parsed=parsed,
input_tokens=input_tokens, input_tokens=input_tokens,
@@ -997,6 +1001,7 @@ async def generate_status_update(
step = AISessionStep( step = AISessionStep(
id=uuid.uuid4(), id=uuid.uuid4(),
session_id=session.id, session_id=session.id,
account_id=session.account_id,
branch_id=session.active_branch_id if session.is_branching else None, branch_id=session.active_branch_id if session.is_branching else None,
step_order=session.step_count, step_order=session.step_count,
step_type="status_update", step_type="status_update",
@@ -1440,6 +1445,7 @@ def _format_engineer_response(request: StepResponseRequest) -> str:
def _create_step_from_parsed( def _create_step_from_parsed(
session_id: UUID, session_id: UUID,
account_id: UUID,
step_order: int, step_order: int,
parsed: dict[str, Any], parsed: dict[str, Any],
input_tokens: int, input_tokens: int,
@@ -1487,6 +1493,7 @@ def _create_step_from_parsed(
return AISessionStep( return AISessionStep(
id=uuid.uuid4(), id=uuid.uuid4(),
session_id=session_id, session_id=session_id,
account_id=account_id,
branch_id=branch_id, branch_id=branch_id,
step_order=step_order, step_order=step_order,
step_type=step_type if parsed["type"] != "resolution_suggestion" else "action", step_type=step_type if parsed["type"] != "resolution_suggestion" else "action",

View File

@@ -56,6 +56,7 @@ class HandoffManager:
handoff = SessionHandoff( handoff = SessionHandoff(
session_id=session_id, session_id=session_id,
account_id=session.account_id,
handed_off_by=user_id, handed_off_by=user_id,
intent=intent, intent=intent,
source_branch_id=session.active_branch_id, source_branch_id=session.active_branch_id,

View File

@@ -10,7 +10,7 @@ import logging
from sqlalchemy import select from sqlalchemy import select
from app.core.database import async_session_maker from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.ai_session import AISession from app.models.ai_session import AISession
from app.services.knowledge_flywheel import analyze_session from app.services.knowledge_flywheel import analyze_session

View File

@@ -0,0 +1,151 @@
"""AI service for generating network diagrams from natural language."""
import json
import logging
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.schemas.network_diagram import (
AIGenerateRequest,
AIGenerateResponse,
DiagramNode,
DiagramEdge,
DeviceProperties,
Position,
)
logger = logging.getLogger(__name__)
SYSTEM_PROMPT_TEMPLATE = """You are a network diagram generator for MSP engineers.
Given a plain English description of a network, you must return ONLY valid JSON with no markdown, no explanation, no preamble.
Return this exact structure:
{{
"nodes": [
{{
"id": "unique-string",
"type": "device-type-slug",
"label": "device label",
"position": {{ "x": number, "y": number }},
"properties": {{
"hostname": "string or null",
"ip": "string or null",
"subnet": "string or null",
"vendor": "string or null",
"model": "string or null",
"role": "string or null",
"vlan": "string or null",
"notes": "string or null",
"status": "unknown"
}}
}}
],
"edges": [
{{
"id": "unique-string",
"source": "node-id",
"target": "node-id",
"label": "connection label or null",
"connectionType": "ethernet|fiber|wifi|vpn|vlan|wan",
"speed": "string or null",
"notes": "string or null"
}}
],
"suggestedName": "short descriptive diagram name",
"notes": "any important assumptions or missing info, or null"
}}
Available device type slugs: {available_slugs}
Position nodes thoughtfully in a logical network topology layout.
Use x/y coordinates between 0 and 1200 for x, 0 and 800 for y.
Place WAN/internet at top, core network in middle, endpoints at bottom.
{merge_instructions}"""
MERGE_INSTRUCTIONS = """
IMPORTANT: You are ADDING devices to an existing diagram. Do NOT replace existing devices.
The existing diagram occupies this bounding box: minX={minX}, maxX={maxX}, minY={minY}, maxY={maxY}.
Place all new nodes OUTSIDE this bounding box — below (y > {maxY} + 100) or to the right (x > {maxX} + 100).
You may create edges that connect new nodes to existing nodes if the description implies a connection.
Use these existing node IDs for connections: {existing_node_ids}"""
async def generate_diagram(
request: AIGenerateRequest,
available_slugs: list[str],
existing_node_ids: list[str] | None = None,
) -> AIGenerateResponse:
merge_instructions = ""
if request.mode == "merge" and request.existingBounds:
b = request.existingBounds
merge_instructions = MERGE_INSTRUCTIONS.format(
minX=b.minX, maxX=b.maxX, minY=b.minY, maxY=b.maxY,
existing_node_ids=", ".join(existing_node_ids or []),
)
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
available_slugs=", ".join(available_slugs),
merge_instructions=merge_instructions,
)
model = settings.get_model_for_action("network_diagram_generate")
provider = get_ai_provider(model)
messages = [{"role": "user", "content": request.description}]
response_text, input_tokens, output_tokens = await provider.generate_json(
system_prompt=system_prompt,
messages=messages,
max_tokens=4096,
)
logger.info(
"Network diagram AI generation: input_tokens=%d, output_tokens=%d",
input_tokens, output_tokens,
)
try:
data = json.loads(response_text)
except json.JSONDecodeError as e:
logger.error("Failed to parse AI response as JSON: %s", e)
raise ValueError("AI generated an invalid response, please try again")
try:
nodes = []
for raw_node in data.get("nodes", []):
node_type = raw_node.get("type", "server")
if node_type not in available_slugs:
logger.warning("Unknown device type '%s', falling back to 'server'", node_type)
node_type = "server"
nodes.append(DiagramNode(
id=raw_node["id"],
type=node_type,
label=raw_node.get("label", node_type),
position=Position(**raw_node.get("position", {"x": 0, "y": 0})),
properties=DeviceProperties(**{
k: v for k, v in raw_node.get("properties", {}).items()
if k in DeviceProperties.model_fields
}),
))
edges = []
for raw_edge in data.get("edges", []):
edges.append(DiagramEdge(
id=raw_edge["id"],
source=raw_edge["source"],
target=raw_edge["target"],
label=raw_edge.get("label"),
connectionType=raw_edge.get("connectionType", "ethernet"),
speed=raw_edge.get("speed"),
notes=raw_edge.get("notes"),
))
except KeyError as e:
logger.warning("AI response missing required field: %s", e)
raise ValueError(f"AI generated incomplete data (missing {e}), please try again")
return AIGenerateResponse(
nodes=nodes,
edges=edges,
suggestedName=data.get("suggestedName"),
notes=data.get("notes"),
)

View File

@@ -11,6 +11,11 @@ from app.services.psa.types import (
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry, PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
) )
@@ -27,7 +32,7 @@ class AutotaskProvider(PSAProvider):
async def get_ticket(self, ticket_id: str) -> PSATicket: async def get_ticket(self, ticket_id: str) -> PSATicket:
raise NotImplementedError("Autotask integration coming soon") raise NotImplementedError("Autotask integration coming soon")
async def search_tickets(self, query: str, **filters) -> list[PSATicket]: async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
raise NotImplementedError("Autotask integration coming soon") raise NotImplementedError("Autotask integration coming soon")
async def post_note( async def post_note(
@@ -58,6 +63,9 @@ class AutotaskProvider(PSAProvider):
async def list_members(self) -> list[PSAMember]: async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Autotask integration coming soon") raise NotImplementedError("Autotask integration coming soon")
async def list_boards(self) -> list[PSABoard]:
raise NotImplementedError("list_boards not implemented for this provider")
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]: async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Autotask integration coming soon") raise NotImplementedError("Autotask integration coming soon")
@@ -70,3 +78,18 @@ class AutotaskProvider(PSAProvider):
work_type: str | None = None, work_type: str | None = None,
) -> PSATimeEntry: ) -> PSATimeEntry:
raise NotImplementedError("Autotask integration coming soon") raise NotImplementedError("Autotask integration coming soon")
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
raise NotImplementedError("Autotask integration coming soon")
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
raise NotImplementedError("Autotask integration coming soon")
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
raise NotImplementedError("Autotask integration coming soon")
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
raise NotImplementedError("Autotask integration coming soon")
async def list_priorities(self) -> list[dict]:
raise NotImplementedError("Autotask integration coming soon")

View File

@@ -12,6 +12,11 @@ from .types import (
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry, PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
) )
@@ -27,7 +32,7 @@ class PSAProvider(ABC):
... ...
@abstractmethod @abstractmethod
async def search_tickets(self, query: str, **filters) -> list[PSATicket]: async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
... ...
@abstractmethod @abstractmethod
@@ -64,6 +69,10 @@ class PSAProvider(ABC):
async def list_members(self) -> list[PSAMember]: async def list_members(self) -> list[PSAMember]:
... ...
@abstractmethod
async def list_boards(self) -> list[PSABoard]:
...
@abstractmethod @abstractmethod
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]: async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
... ...
@@ -78,3 +87,23 @@ class PSAProvider(ABC):
work_type: str | None = None, work_type: str | None = None,
) -> PSATimeEntry: ) -> PSATimeEntry:
... ...
@abstractmethod
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
...
@abstractmethod
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
...
@abstractmethod
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
...
@abstractmethod
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
...
@abstractmethod
async def list_priorities(self) -> list[dict]:
...

View File

@@ -16,6 +16,11 @@ from app.services.psa.types import (
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry, PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
) )
from .client import ConnectWiseClient from .client import ConnectWiseClient
@@ -54,15 +59,19 @@ class ConnectWiseProvider(PSAProvider):
) )
return self._map_ticket(data) return self._map_ticket(data)
async def search_tickets(self, query: str, **filters) -> list[PSATicket]: async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
"""Search CW tickets by summary. Supports board_id and status_id filters.""" """Search CW tickets by summary. Supports board_id, status_id, member_identifier,
unassigned, board_ids, page, and page_size filters. Returns paginated result."""
page_size = filters.get("page_size", 10)
page = filters.get("page", 1)
params: dict = { params: dict = {
"fields": "id,summary,company,board,status,priority,closedFlag", "fields": "id,summary,company,board,status,priority,closedFlag",
"orderBy": "id desc", "orderBy": "priority/sort asc,dateEntered desc",
"pageSize": 25, "pageSize": page_size,
"page": page,
} }
# Build CW condition query
conditions: list[str] = [] conditions: list[str] = []
if query: if query:
conditions.append(f"summary contains '{query}'") conditions.append(f"summary contains '{query}'")
@@ -72,16 +81,33 @@ class ConnectWiseProvider(PSAProvider):
conditions.append(f"status/id = {filters['status_id']}") conditions.append(f"status/id = {filters['status_id']}")
if not filters.get("include_closed", False): if not filters.get("include_closed", False):
conditions.append("closedFlag = false") conditions.append("closedFlag = false")
if filters.get("member_identifier") is not None:
conditions.append(f"resources contains '{filters['member_identifier']}'")
if filters.get("unassigned", False):
conditions.append("resources = null")
board_ids: list[int] = filters.get("board_ids") or []
if board_ids:
board_list = ", ".join(str(bid) for bid in board_ids)
conditions.append(f"board/id in ({board_list})")
if conditions: condition_str = " and ".join(conditions) if conditions else ""
params["conditions"] = " and ".join(conditions) if condition_str:
params["conditions"] = condition_str
data = await self.client.get("/service/tickets", params=params) count_params: dict = {}
if condition_str:
count_params["conditions"] = condition_str
return [ # Fire page fetch + count in parallel
self._map_ticket(t) data, count_data = await asyncio.gather(
for t in (data if isinstance(data, list) else []) self.client.get("/service/tickets", params=params),
] self.client.get("/service/tickets/count", params=count_params),
)
items = [self._map_ticket(t) for t in (data if isinstance(data, list) else [])]
total = count_data.get("count", len(items)) if isinstance(count_data, dict) else len(items)
return PaginatedTicketResult(items=items, total=total, page=page, page_size=page_size)
async def get_ticket_configurations( async def get_ticket_configurations(
self, ticket_id: str self, ticket_id: str
@@ -270,6 +296,32 @@ class ConnectWiseProvider(PSAProvider):
psa_cache.set(cache_key, result, ttl_seconds=900) psa_cache.set(cache_key, result, ttl_seconds=900)
return result return result
async def list_boards(self) -> list[PSABoard]:
"""List active CW service boards (cached 1 hour)."""
cache_key = "boards"
cached = psa_cache.get(cache_key)
if cached is not None:
return cached
data = await self.client.get(
"/service/boards",
params={
"fields": "id,name,inactiveFlag",
"conditions": "inactiveFlag = false",
"pageSize": 100,
},
)
result = [
PSABoard(
id=b["id"],
name=b["name"],
inactive=b.get("inactiveFlag", False),
)
for b in (data if isinstance(data, list) else [])
]
psa_cache.set(cache_key, result, ttl_seconds=3600)
return result
# ── Ticket Context ──────────────────────────────────────────────── # ── Ticket Context ────────────────────────────────────────────────
async def get_ticket_context( async def get_ticket_context(
@@ -536,7 +588,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,
@@ -551,16 +603,112 @@ class ConnectWiseProvider(PSAProvider):
@staticmethod @staticmethod
def _map_ticket(data: dict) -> PSATicket: def _map_ticket(data: dict) -> PSATicket:
"""Map a CW ticket JSON dict to a PSATicket.""" """Map a CW ticket JSON dict to a PSATicket."""
company = data.get("company") or {}
board = data.get("board") or {}
status = data.get("status") or {}
priority = data.get("priority") or {}
return PSATicket( return PSATicket(
id=str(data["id"]), id=str(data.get("id", "")),
summary=data.get("summary", ""), summary=data.get("summary", ""),
company_name=data.get("company", {}).get("name"), company_name=company.get("name"),
company_id=str(data["company"]["id"]) if data.get("company") else None, company_id=str(company.get("id")) if company.get("id") else None,
board_name=data.get("board", {}).get("name"), board_name=board.get("name"),
board_id=data.get("board", {}).get("id"), board_id=board.get("id"),
status_name=data.get("status", {}).get("name"), status_name=status.get("name"),
status_id=data.get("status", {}).get("id"), status_id=status.get("id"),
priority_name=data.get("priority", {}).get("name"), priority_name=priority.get("name"),
priority_id=data.get("priority", {}).get("id"), priority_id=priority.get("id"),
closed=data.get("closedFlag", False), closed=data.get("closedFlag", False),
) )
# ── Resource management ───────────────────────────────────────────
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
"""List members assigned to a CW ticket."""
data = await self.client.get(f"/service/tickets/{ticket_id}/members")
results = []
for m in (data if isinstance(data, list) else []):
member = m.get("member") or {}
results.append(PSAResource(
member_id=member.get("id", 0),
member_name=member.get("name", ""),
member_identifier=member.get("identifier", ""),
))
return results
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
"""Assign a member to a CW ticket."""
data = await self.client.post(
f"/service/tickets/{ticket_id}/members",
json_body={"member": {"id": member_id}},
)
member = (data.get("member") or {}) if isinstance(data, dict) else {}
return PSAResource(
member_id=member.get("id", member_id),
member_name=member.get("name", ""),
member_identifier=member.get("identifier", ""),
)
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
"""Remove a member from a CW ticket (idempotent)."""
# CW DELETE requires the member record id (junction record), not the member's id
members_data = await self.client.get(f"/service/tickets/{ticket_id}/members")
record_id = None
for m in (members_data if isinstance(members_data, list) else []):
if (m.get("member") or {}).get("id") == member_id:
record_id = m.get("id")
break
if record_id is None:
return # Already not assigned — idempotent
await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}")
# ── Ticket creation ───────────────────────────────────────────────
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
"""Create a new CW service ticket."""
body: dict = {
"summary": payload.summary,
"board": {"id": payload.board_id},
"company": {"id": payload.company_id},
"status": {"id": payload.status_id},
"priority": {"id": payload.priority_id},
}
if payload.description:
body["initialDescription"] = payload.description
if payload.assigned_member_id:
body["owner"] = {"id": payload.assigned_member_id}
data = await self.client.post("/service/tickets", json_body=body)
ticket_id = data.get("id") if isinstance(data, dict) else None
resources: list[PSAResource] = []
if ticket_id and payload.assigned_member_id:
try:
resources = await self.list_resources(ticket_id)
except Exception:
pass
company = (data.get("company") or {}) if isinstance(data, dict) else {}
board = (data.get("board") or {}) if isinstance(data, dict) else {}
status = (data.get("status") or {}) if isinstance(data, dict) else {}
priority = (data.get("priority") or {}) if isinstance(data, dict) else {}
return PSACreatedTicket(
id=ticket_id or 0,
summary=data.get("summary", payload.summary) if isinstance(data, dict) else payload.summary,
board_name=board.get("name", ""),
status_name=status.get("name", ""),
priority_name=priority.get("name", ""),
company_name=company.get("name", ""),
resources=resources,
)
# ── Priorities ────────────────────────────────────────────────────
async def list_priorities(self) -> list[dict]:
"""List CW service priorities."""
data = await self.client.get("/service/priorities", params={"pageSize": 50})
return [
{"id": p.get("id"), "name": p.get("name")}
for p in (data if isinstance(data, list) else [])
]

View File

@@ -11,6 +11,11 @@ from app.services.psa.types import (
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry, PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
) )
@@ -27,7 +32,7 @@ class HaloPSAProvider(PSAProvider):
async def get_ticket(self, ticket_id: str) -> PSATicket: async def get_ticket(self, ticket_id: str) -> PSATicket:
raise NotImplementedError("Halo PSA integration coming soon") raise NotImplementedError("Halo PSA integration coming soon")
async def search_tickets(self, query: str, **filters) -> list[PSATicket]: async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
raise NotImplementedError("Halo PSA integration coming soon") raise NotImplementedError("Halo PSA integration coming soon")
async def post_note( async def post_note(
@@ -58,6 +63,9 @@ class HaloPSAProvider(PSAProvider):
async def list_members(self) -> list[PSAMember]: async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Halo PSA integration coming soon") raise NotImplementedError("Halo PSA integration coming soon")
async def list_boards(self) -> list[PSABoard]:
raise NotImplementedError("list_boards not implemented for this provider")
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]: async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Halo PSA integration coming soon") raise NotImplementedError("Halo PSA integration coming soon")
@@ -70,3 +78,18 @@ class HaloPSAProvider(PSAProvider):
work_type: str | None = None, work_type: str | None = None,
) -> PSATimeEntry: ) -> PSATimeEntry:
raise NotImplementedError("Halo PSA integration coming soon") raise NotImplementedError("Halo PSA integration coming soon")
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
raise NotImplementedError("Halo PSA integration coming soon")
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
raise NotImplementedError("Halo PSA integration coming soon")
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
raise NotImplementedError("Halo PSA integration coming soon")
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
raise NotImplementedError("Halo PSA integration coming soon")
async def list_priorities(self) -> list[dict]:
raise NotImplementedError("Halo PSA integration coming soon")

View File

@@ -67,6 +67,46 @@ class PSATimeEntry(BaseModel):
created_at: str | None = None created_at: str | None = None
class PSABoard(BaseModel):
id: int
name: str
inactive: bool = False
class PaginatedTicketResult(BaseModel):
items: list[PSATicket]
total: int
page: int
page_size: int
class PSAResource(BaseModel):
member_id: int
member_name: str
member_identifier: str
is_rf_user: bool = False
class PSACreatedTicket(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResource] = []
class TicketCreatePayload(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class NoteType: class NoteType:
INTERNAL_ANALYSIS = "internal_analysis" INTERNAL_ANALYSIS = "internal_analysis"
RESOLUTION = "resolution" RESOLUTION = "resolution"

View File

@@ -371,6 +371,7 @@ async def push_documentation(
# Log success # Log success
log_entry = PsaPostLog( log_entry = PsaPostLog(
id=uuid.uuid4(), id=uuid.uuid4(),
account_id=session.account_id,
ai_session_id=session.id, ai_session_id=session.id,
psa_connection_id=session.psa_connection_id, psa_connection_id=session.psa_connection_id,
ticket_id=session.psa_ticket_id, ticket_id=session.psa_ticket_id,
@@ -394,6 +395,7 @@ async def push_documentation(
# Log failure with retry scheduling # Log failure with retry scheduling
log_entry = PsaPostLog( log_entry = PsaPostLog(
id=uuid.uuid4(), id=uuid.uuid4(),
account_id=session.account_id,
ai_session_id=session.id, ai_session_id=session.id,
psa_connection_id=session.psa_connection_id, psa_connection_id=session.psa_connection_id,
ticket_id=session.psa_ticket_id, ticket_id=session.psa_ticket_id,

View File

@@ -9,7 +9,7 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import async_session_maker from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.psa_post_log import PsaPostLog from app.models.psa_post_log import PsaPostLog
from app.services.psa_documentation_service import retry_failed_push from app.services.psa_documentation_service import retry_failed_push

View File

@@ -45,6 +45,7 @@ class ResolutionOutputGenerator:
output = SessionResolutionOutput( output = SessionResolutionOutput(
session_id=session_id, session_id=session_id,
account_id=session.account_id,
output_type=output_type, output_type=output_type,
generated_content=content, generated_content=content,
status="draft", status="draft",

View File

@@ -9,7 +9,7 @@ from datetime import datetime, timezone, timedelta
from sqlalchemy import select, delete, func from sqlalchemy import select, delete, func
from app.core.database import async_session_maker from app.core.admin_database import _admin_session_factory as async_session_maker
from app.models.account import Account from app.models.account import Account
from app.models.assistant_chat import AssistantChat from app.models.assistant_chat import AssistantChat

View File

@@ -144,6 +144,7 @@ def _extract_script_from_response(content: str, language: str) -> tuple[str | No
async def create_session( async def create_session(
db: AsyncSession, db: AsyncSession,
user_id: UUID, user_id: UUID,
account_id: UUID,
team_id: UUID | None, team_id: UUID | None,
language: str, language: str,
initial_prompt: str | None = None, initial_prompt: str | None = None,
@@ -151,6 +152,7 @@ async def create_session(
"""Create a new Script Builder session.""" """Create a new Script Builder session."""
session = ScriptBuilderSession( session = ScriptBuilderSession(
user_id=user_id, user_id=user_id,
account_id=account_id,
team_id=team_id, team_id=team_id,
language=language, language=language,
) )

View File

@@ -0,0 +1,115 @@
"""Ticket mutation service — wraps PSA provider, resolves is_rf_user flag."""
from __future__ import annotations
import logging
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.psa_connection import PsaConnection
from app.models.psa_member_mapping import PsaMemberMapping
from app.schemas.psa_tickets import (
PSAResourceSchema,
PSATicketCreatedSchema,
PSATicketStatusUpdateSchema,
)
from app.services.psa.registry import get_provider_for_account
from app.services.psa.types import TicketCreatePayload
logger = logging.getLogger(__name__)
async def _get_mapped_member_ids(account_id: UUID, db: AsyncSession) -> set[int]:
"""Return set of external_member_id ints that are mapped to RF users."""
conn_result = await db.execute(
select(PsaConnection).where(PsaConnection.account_id == account_id)
)
conn = conn_result.scalar_one_or_none()
if not conn:
return set()
mappings = await db.execute(
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
return {int(m.external_member_id) for m in mappings.scalars().all() if m.external_member_id}
async def list_resources(
account_id: UUID, ticket_id: int, db: AsyncSession
) -> list[PSAResourceSchema]:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
resources = await provider.list_resources(ticket_id)
return [
PSAResourceSchema(
member_id=r.member_id,
member_name=r.member_name,
member_identifier=r.member_identifier,
is_rf_user=r.member_id in mapped_ids,
)
for r in resources
]
async def add_resource(
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
) -> PSAResourceSchema:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
resource = await provider.add_resource(ticket_id, member_id)
return PSAResourceSchema(
member_id=resource.member_id,
member_name=resource.member_name,
member_identifier=resource.member_identifier,
is_rf_user=resource.member_id in mapped_ids,
)
async def remove_resource(
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
) -> None:
provider = await get_provider_for_account(account_id, db)
await provider.remove_resource(ticket_id, member_id)
async def update_status(
account_id: UUID, ticket_id: int, status_id: int, db: AsyncSession
) -> PSATicketStatusUpdateSchema:
provider = await get_provider_for_account(account_id, db)
# get current status before updating
ticket = await provider.get_ticket(str(ticket_id))
previous_status = ticket.status_name or ""
await provider.update_ticket_status(str(ticket_id), status_id)
# get new status name from statuses list
statuses = await provider.get_ticket_statuses(ticket.board_id or 0)
new_status = next((s.name for s in statuses if s.id == status_id), str(status_id))
return PSATicketStatusUpdateSchema(
ticket_id=ticket_id,
previous_status=previous_status,
new_status=new_status,
)
async def create_ticket(
account_id: UUID, payload: TicketCreatePayload, db: AsyncSession
) -> PSATicketCreatedSchema:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
result = await provider.create_ticket(payload)
return PSATicketCreatedSchema(
id=result.id,
summary=result.summary,
board_name=result.board_name,
status_name=result.status_name,
priority_name=result.priority_name,
company_name=result.company_name,
resources=[
PSAResourceSchema(
member_id=r.member_id,
member_name=r.member_name,
member_identifier=r.member_identifier,
is_rf_user=r.member_id in mapped_ids,
)
for r in result.resources
],
)

View File

@@ -80,7 +80,10 @@ def _display_code() -> str:
async def main() -> None: async def main() -> None:
engine = create_async_engine(settings.DATABASE_URL, echo=False) # Must use ADMIN_DATABASE_URL (BYPASSRLS) — Phase 4 enabled RLS on users.
# The app-role connection has no tenant context at seed time and would see 0 rows.
admin_url = getattr(settings, "ADMIN_DATABASE_URL", None) or settings.DATABASE_URL
engine = create_async_engine(admin_url, echo=False)
password_hash = get_password_hash(SHARED_PASSWORD) password_hash = get_password_hash(SHARED_PASSWORD)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
team_account_id: uuid.UUID | None = None team_account_id: uuid.UUID | None = None

View File

@@ -75,6 +75,19 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]') ('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
""")) """))
# Seed the platform/system account (PLATFORM_ACCOUNT_ID) needed by
# global categories, gallery items, and other platform-owned content.
await conn.execute(sa.text("""
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
VALUES (
'00000000-0000-0000-0000-000000000001',
'ResolutionFlow System',
'RF-SYS-1',
NOW(), NOW()
)
ON CONFLICT (id) DO NOTHING
"""))
# Create async session maker # Create async session maker
async_session_maker = async_sessionmaker( async_session_maker = async_sessionmaker(
engine, engine,

View File

@@ -19,8 +19,116 @@ class TestAdminEndpoints:
"/api/v1/admin/users", headers=admin_auth_headers "/api/v1/admin/users", headers=admin_auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
users = response.json() payload = response.json()
assert len(users) >= 2 # admin + test_user assert payload["total"] >= 2 # admin + test_user
assert len(payload["items"]) >= 2
@pytest.mark.asyncio
async def test_list_users_supports_search(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test admin people search by user email."""
response = await client.get(
"/api/v1/admin/users",
params={"search": test_user["email"]},
headers=admin_auth_headers,
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] >= 1
assert any(item["email"] == test_user["email"] for item in payload["items"])
@pytest.mark.asyncio
async def test_list_accounts_as_admin(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Test listing accounts with member data."""
response = await client.get(
"/api/v1/admin/accounts", headers=admin_auth_headers
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] >= 1
assert len(payload["items"]) >= 1
assert "members" in payload["items"][0]
assert "subscription" in payload["items"][0]
@pytest.mark.asyncio
async def test_create_account_as_admin(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Test creating an empty account from admin."""
response = await client.post(
"/api/v1/admin/accounts",
json={"name": "Acme Customer", "plan": "pro"},
headers=admin_auth_headers,
)
assert response.status_code == 201
payload = response.json()
assert payload["name"] == "Acme Customer"
assert payload["subscription"]["plan"] == "pro"
assert payload["display_code"]
@pytest.mark.asyncio
async def test_get_account_detail_as_admin(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test fetching account detail for management view."""
account_id = test_user["user_data"]["account_id"]
response = await client.get(
f"/api/v1/admin/accounts/{account_id}",
headers=admin_auth_headers,
)
assert response.status_code == 200
payload = response.json()
assert payload["id"] == account_id
assert "members" in payload
assert "invites" in payload
@pytest.mark.asyncio
async def test_update_account_name_as_admin(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test renaming an account from admin detail view."""
account_id = test_user["user_data"]["account_id"]
response = await client.put(
f"/api/v1/admin/accounts/{account_id}",
json={"name": "Renamed Customer Account"},
headers=admin_auth_headers,
)
assert response.status_code == 200
payload = response.json()
assert payload["id"] == account_id
assert payload["name"] == "Renamed Customer Account"
@pytest.mark.asyncio
async def test_update_account_plan(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test changing an account's subscription plan."""
account_id = test_user["user_data"]["account_id"]
response = await client.put(
f"/api/v1/admin/accounts/{account_id}/subscription/plan",
json={"plan": "pro"},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["plan"] == "pro"
@pytest.mark.asyncio
async def test_extend_account_trial(
self, client: AsyncClient, admin_auth_headers: dict, test_user: dict
):
"""Test starting or extending an account trial."""
account_id = test_user["user_data"]["account_id"]
response = await client.put(
f"/api/v1/admin/accounts/{account_id}/subscription/extend-trial",
json={"days": 14},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["status"] == "trialing"
assert response.json()["current_period_end"] is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_users_as_non_admin( async def test_list_users_as_non_admin(

View File

@@ -29,7 +29,7 @@ class TestAdminGlobalCategories:
data = response.json() data = response.json()
assert data["name"] == "Test Category" assert data["name"] == "Test Category"
assert data["slug"] == "test-category" assert data["slug"] == "test-category"
assert data["account_id"] is None assert data["account_id"] == "00000000-0000-0000-0000-000000000001" # PLATFORM_ACCOUNT_ID
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_global_category( async def test_update_global_category(

View File

@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tree import Tree from app.models.tree import Tree
from app.models.script_template import ScriptTemplate, ScriptCategory from app.models.script_template import ScriptTemplate, ScriptCategory
_PLATFORM_ACCOUNT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -22,6 +23,7 @@ async def _create_tree(db: AsyncSession, admin_user_id: str) -> Tree:
name="Gallery Test Flow", name="Gallery Test Flow",
tree_type="troubleshooting", tree_type="troubleshooting",
visibility="public", visibility="public",
account_id=_PLATFORM_ACCOUNT_ID,
is_gallery_featured=False, is_gallery_featured=False,
gallery_sort_order=0, gallery_sort_order=0,
tree_structure={ tree_structure={
@@ -53,6 +55,7 @@ async def _create_script(db: AsyncSession, admin_user_id: str) -> ScriptTemplate
script = ScriptTemplate( script = ScriptTemplate(
id=uuid.uuid4(), id=uuid.uuid4(),
category_id=category.id, category_id=category.id,
account_id=_PLATFORM_ACCOUNT_ID,
name="Gallery Test Script", name="Gallery Test Script",
slug=f"gallery-test-script-{uuid.uuid4().hex[:6]}", slug=f"gallery-test-script-{uuid.uuid4().hex[:6]}",
script_body="Write-Host 'Test'", script_body="Write-Host 'Test'",

View File

@@ -594,6 +594,7 @@ class TestPsaMetrics:
post_log = PsaPostLog( post_log = PsaPostLog(
id=uuid.uuid4(), id=uuid.uuid4(),
ai_session_id=push_session_id, ai_session_id=push_session_id,
account_id=account_id,
ticket_id="TICKET-123", ticket_id="TICKET-123",
note_type="internal", note_type="internal",
content_posted="Session summary", content_posted="Session summary",

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.core.security import get_password_hash from app.core.security import get_password_hash
from app.models.account import Account
from app.models.team import Team from app.models.team import Team
from app.models.user import User from app.models.user import User
@@ -23,6 +24,8 @@ async def _create_team_with_admin(
team_name: str = "Branding Test Team", team_name: str = "Branding Test Team",
) -> tuple[dict, str, Team]: ) -> tuple[dict, str, Team]:
"""Create a team + team admin user. Returns (auth_headers, team_id_str, team).""" """Create a team + team admin user. Returns (auth_headers, team_id_str, team)."""
account = Account(name=team_name, display_code=uuid.uuid4().hex[:8].upper())
test_db.add(account)
team = Team(name=team_name) team = Team(name=team_name)
test_db.add(team) test_db.add(team)
await test_db.flush() await test_db.flush()
@@ -36,6 +39,8 @@ async def _create_team_with_admin(
team_id=team.id, team_id=team.id,
is_team_admin=True, is_team_admin=True,
role="engineer", role="engineer",
account_id=account.id,
account_role="engineer",
) )
test_db.add(user) test_db.add(user)
await test_db.commit() await test_db.commit()
@@ -58,6 +63,15 @@ async def _create_team_member(
is_team_admin: bool = False, is_team_admin: bool = False,
) -> dict: ) -> dict:
"""Create a regular team member. Returns auth_headers.""" """Create a regular team member. Returns auth_headers."""
# Look up the account associated with this team via an existing member
from sqlalchemy import select as _select
from app.models.user import User as _User
result = await test_db.execute(
_select(_User).where(_User.team_id == team.id).limit(1)
)
team_member = result.scalar_one_or_none()
member_account_id = team_member.account_id if team_member else None
email = f"member_{uuid.uuid4().hex[:8]}@test.com" email = f"member_{uuid.uuid4().hex[:8]}@test.com"
user = User( user = User(
email=email, email=email,
@@ -67,6 +81,8 @@ async def _create_team_member(
team_id=team.id, team_id=team.id,
is_team_admin=is_team_admin, is_team_admin=is_team_admin,
role="engineer", role="engineer",
account_id=member_account_id,
account_role="engineer",
) )
test_db.add(user) test_db.add(user)
await test_db.commit() await test_db.commit()

View File

@@ -334,12 +334,13 @@ class TestDraftTreesAPI:
"""Test that migration defaults existing trees to published status.""" """Test that migration defaults existing trees to published status."""
# Create a tree without specifying status (relies on DB default) # Create a tree without specifying status (relies on DB default)
from uuid import UUID, uuid4 from uuid import UUID, uuid4
_platform_id = UUID("00000000-0000-0000-0000-000000000001")
tree = Tree( tree = Tree(
name="Legacy Tree", name="Legacy Tree",
description="Created before status field", description="Created before status field",
tree_structure={"id": "root", "type": "solution", "title": "Fix"}, tree_structure={"id": "root", "type": "solution", "title": "Fix"},
author_id=None, author_id=None,
account_id=None account_id=_platform_id,
) )
test_db.add(tree) test_db.add(tree)
await test_db.commit() await test_db.commit()

View File

@@ -127,10 +127,12 @@ async def test_cannot_schedule_other_teams_tree(client: AsyncClient, auth_header
test_db.add(other_team) test_db.add(other_team)
await test_db.flush() await test_db.flush()
from uuid import UUID as _UUID
other_tree = Tree( other_tree = Tree(
name="Other Team Tree", name="Other Team Tree",
tree_type="maintenance", tree_type="maintenance",
team_id=other_team.id, team_id=other_team.id,
account_id=_UUID("00000000-0000-0000-0000-000000000001"),
tree_structure={ tree_structure={
"steps": [ "steps": [
{"id": "s1", "type": "procedure_step", "title": "Step", {"id": "s1", "type": "procedure_step", "title": "Step",

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import select
from app.models.device_type import DeviceType
from app.models.user import User
from app.core.service_account import PLATFORM_ACCOUNT_ID
async def _login_headers(client, email: str, password: str) -> dict[str, str]:
response = await client.post(
"/api/v1/auth/login/json",
json={"email": email, "password": password},
)
assert response.status_code == 200
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_device_types_include_platform_and_account_custom(client, test_db, auth_headers, test_user):
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
user = result.scalar_one()
test_db.add(
DeviceType(
id=uuid.uuid4(),
slug="platform-router",
label="Platform Router",
category="network",
is_system=True,
account_id=PLATFORM_ACCOUNT_ID,
sort_order=0,
)
)
await test_db.commit()
create_response = await client.post(
"/api/v1/device-types/",
json={
"slug": "tenant-appliance",
"label": "Tenant Appliance",
"category": "network",
"sort_order": 3,
},
headers=auth_headers,
)
assert create_response.status_code == 201
assert create_response.json()["account_id"] == str(user.account_id)
list_response = await client.get("/api/v1/device-types/", headers=auth_headers)
assert list_response.status_code == 200
payload = list_response.json()
slugs = {item["slug"] for item in payload}
assert "platform-router" in slugs
assert "tenant-appliance" in slugs
@pytest.mark.asyncio
async def test_network_diagrams_are_account_scoped(client, test_db, auth_headers, test_user):
other_user = {
"email": "other-network@example.com",
"password": "TestPassword123!",
"name": "Other Network User",
}
register_response = await client.post("/api/v1/auth/register", json=other_user)
assert register_response.status_code in (200, 201)
other_headers = await _login_headers(client, other_user["email"], other_user["password"])
owner_result = await test_db.execute(select(User).where(User.email == test_user["email"]))
owner = owner_result.scalar_one()
create_response = await client.post(
"/api/v1/network-diagrams/",
json={
"name": "HQ Core",
"client_name": "Acme",
"description": "Primary topology",
"nodes": [],
"edges": [],
},
headers=auth_headers,
)
assert create_response.status_code == 201
diagram = create_response.json()
assert diagram["account_id"] == str(owner.account_id)
own_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=auth_headers)
assert own_get.status_code == 200
other_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=other_headers)
assert other_get.status_code == 404

View File

@@ -200,6 +200,7 @@ class TestAccountPermissions:
}) })
outsider_headers = {"Authorization": f"Bearer {outsider_login.json()['access_token']}"} outsider_headers = {"Authorization": f"Bearer {outsider_login.json()['access_token']}"}
# Outsider should NOT see the private tree # Outsider should NOT see the private tree.
# With RLS, the tree is invisible to other tenants — 404 not 403.
response = await client.get(f"/api/v1/trees/{tree_id}", headers=outsider_headers) response = await client.get(f"/api/v1/trees/{tree_id}", headers=outsider_headers)
assert response.status_code == 403 assert response.status_code == 404

View File

@@ -464,7 +464,6 @@ async def test_target_list_account_id_from_team_admin(test_db: AsyncSession):
await test_db.flush() await test_db.flush()
target_list = TargetList( target_list = TargetList(
team_id=team.id,
account_id=account.id, account_id=account.id,
created_by=user.id, created_by=user.id,
name="Server Targets", name="Server Targets",

View File

@@ -0,0 +1,55 @@
# backend/tests/test_psa_tickets.py
"""Routing and auth tests for new ticket management endpoints."""
import pytest
@pytest.mark.asyncio
async def test_create_ticket_requires_auth(client):
"""POST /tickets returns 401 without auth."""
response = await client.post(
"/api/v1/integrations/psa/tickets",
json={
"summary": "Test", "company_id": 1, "board_id": 1,
"status_id": 1, "priority_id": 1
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_resources_requires_auth(client):
response = await client.get("/api/v1/integrations/psa/tickets/1/resources")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_search_tickets_returns_paginated_shape(client, auth_headers):
"""search endpoint returns TicketListResponse shape when no PSA connected."""
response = await client.get(
"/api/v1/integrations/psa/tickets/search",
headers=auth_headers,
)
# No PSA connection → 400 or 502; with PSA → 200
assert response.status_code in (200, 400, 502)
if response.status_code == 200:
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
@pytest.mark.asyncio
async def test_update_status_requires_auth(client):
response = await client.patch(
"/api/v1/integrations/psa/tickets/1/status?status_id=5"
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_ai_parse_requires_auth(client):
response = await client.post(
"/api/v1/integrations/psa/tickets/ai-parse",
json={"prompt": "New ticket for Acme"},
)
assert response.status_code == 401

View File

@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.script_template import ScriptCategory, ScriptTemplate from app.models.script_template import ScriptCategory, ScriptTemplate
from app.models.tree import Tree from app.models.tree import Tree
_PLATFORM_ACCOUNT_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -41,6 +43,7 @@ async def _create_featured_tree(db: AsyncSession, name: str = "Featured Flow", f
description="A featured flow for the gallery", description="A featured flow for the gallery",
tree_type="troubleshooting", tree_type="troubleshooting",
tree_structure=_make_tree_structure(4), tree_structure=_make_tree_structure(4),
account_id=_PLATFORM_ACCOUNT_ID,
is_gallery_featured=featured, is_gallery_featured=featured,
is_active=True, is_active=True,
usage_count=42, usage_count=42,
@@ -74,6 +77,7 @@ async def _create_featured_script(
) -> ScriptTemplate: ) -> ScriptTemplate:
script = ScriptTemplate( script = ScriptTemplate(
category_id=category.id, category_id=category.id,
account_id=_PLATFORM_ACCOUNT_ID,
name=name, name=name,
slug=name.lower().replace(" ", "-"), slug=name.lower().replace(" ", "-"),
description="A gallery-featured script", description="A gallery-featured script",
@@ -312,7 +316,7 @@ class TestCategoriesEndpoint:
from app.models.category import TreeCategory from app.models.category import TreeCategory
# Create a category and a featured tree in that category # Create a category and a featured tree in that category
cat = TreeCategory(name="Networking", slug="networking", is_active=True) cat = TreeCategory(name="Networking", slug="networking", is_active=True, account_id=_PLATFORM_ACCOUNT_ID)
test_db.add(cat) test_db.add(cat)
await test_db.commit() await test_db.commit()
await test_db.refresh(cat) await test_db.refresh(cat)
@@ -321,6 +325,7 @@ class TestCategoriesEndpoint:
name="Router Diagnostics", name="Router Diagnostics",
tree_type="troubleshooting", tree_type="troubleshooting",
tree_structure=_make_tree_structure(2), tree_structure=_make_tree_structure(2),
account_id=_PLATFORM_ACCOUNT_ID,
is_gallery_featured=True, is_gallery_featured=True,
is_active=True, is_active=True,
usage_count=5, usage_count=5,

View File

@@ -62,6 +62,7 @@ async def test_edit_output(client: AsyncClient, test_user, auth_headers, test_db
output = SessionResolutionOutput( output = SessionResolutionOutput(
session_id=session.id, session_id=session.id,
account_id=session.account_id,
output_type="psa_ticket_notes", output_type="psa_ticket_notes",
generated_content="Original notes", generated_content="Original notes",
status="draft", status="draft",

View File

@@ -16,11 +16,20 @@ Run with:
The test DB is patherly_test (matches conftest.py default). The test DB is patherly_test (matches conftest.py default).
""" """
import os import os
import subprocess
import sys
import uuid import uuid
from pathlib import Path
import asyncpg import asyncpg
import pytest import pytest
# All tests in this module use module-scoped async fixtures (admin_conn,
# seed_rls_test_data) which run on the module event loop. Without this marker,
# pytest-asyncio 0.23+ defaults tests to function-scoped loops, causing
# "Future attached to a different loop" errors on the asyncpg connections.
pytestmark = pytest.mark.asyncio(loop_scope="module")
_DB_HOST = os.getenv("TEST_DB_HOST", "localhost") _DB_HOST = os.getenv("TEST_DB_HOST", "localhost")
_DB_PORT = int(os.getenv("TEST_DB_PORT", "5432")) _DB_PORT = int(os.getenv("TEST_DB_PORT", "5432"))
_DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py _DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py
@@ -37,7 +46,25 @@ ACCOUNT_B_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
async def admin_conn(): def _ensure_rls_schema():
"""Re-apply Alembic migrations before the module runs.
Function-scoped test_db fixtures in other modules drop and recreate the
public schema using Base.metadata.create_all, which does not enable RLS
or create DB roles. This fixture re-runs 'alembic upgrade head' so that
the full migration-managed schema (including RLS policies) is in place.
"""
backend_dir = Path(__file__).parent.parent
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=backend_dir,
check=True,
capture_output=True,
)
@pytest.fixture(scope="module")
async def admin_conn(_ensure_rls_schema):
"""Superuser asyncpg connection for fixture setup and teardown.""" """Superuser asyncpg connection for fixture setup and teardown."""
conn = await asyncpg.connect(_ADMIN_DSN) conn = await asyncpg.connect(_ADMIN_DSN)
yield conn yield conn
@@ -170,7 +197,6 @@ async def conn_no_context():
# trees # trees
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_trees_account_a_cannot_see_account_b_rows(conn_a): async def test_trees_account_a_cannot_see_account_b_rows(conn_a):
rows = await conn_a.fetch( rows = await conn_a.fetch(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}'" f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -178,7 +204,6 @@ async def test_trees_account_a_cannot_see_account_b_rows(conn_a):
assert len(rows) == 0, "Account A should not see Account B trees" assert len(rows) == 0, "Account A should not see Account B trees"
@pytest.mark.asyncio
async def test_trees_account_a_can_see_own_rows(conn_a): async def test_trees_account_a_can_see_own_rows(conn_a):
rows = await conn_a.fetch( rows = await conn_a.fetch(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}'" f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}'"
@@ -186,7 +211,6 @@ async def test_trees_account_a_can_see_own_rows(conn_a):
assert len(rows) >= 1, "Account A should see its own trees" assert len(rows) >= 1, "Account A should see its own trees"
@pytest.mark.asyncio
async def test_trees_no_context_sees_no_private_trees(conn_no_context): async def test_trees_no_context_sees_no_private_trees(conn_no_context):
rows = await conn_no_context.fetch( rows = await conn_no_context.fetch(
"SELECT id FROM trees WHERE is_default = FALSE AND is_public = FALSE" "SELECT id FROM trees WHERE is_default = FALSE AND is_public = FALSE"
@@ -198,7 +222,6 @@ async def test_trees_no_context_sees_no_private_trees(conn_no_context):
# tree_tags — platform visibility # tree_tags — platform visibility
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a): async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a):
rows = await conn_a.fetch( rows = await conn_a.fetch(
f"SELECT id FROM tree_tags WHERE account_id = '{ACCOUNT_B_ID}'" f"SELECT id FROM tree_tags WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -206,7 +229,6 @@ async def test_tree_tags_account_a_cannot_see_account_b_tags(conn_a):
assert len(rows) == 0 assert len(rows) == 0
@pytest.mark.asyncio
async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b): async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b):
rows_a = await conn_a.fetch( rows_a = await conn_a.fetch(
f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'" f"SELECT id FROM tree_tags WHERE account_id = '{PLATFORM_ACCOUNT_ID}'"
@@ -222,7 +244,6 @@ async def test_tree_tags_both_tenants_see_platform_tags(conn_a, conn_b):
# tree_categories — platform visibility # tree_categories — platform visibility
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tree_categories_account_a_cannot_see_account_b(conn_a): async def test_tree_categories_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch( rows = await conn_a.fetch(
f"SELECT id FROM tree_categories WHERE account_id = '{ACCOUNT_B_ID}'" f"SELECT id FROM tree_categories WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -234,7 +255,6 @@ async def test_tree_categories_account_a_cannot_see_account_b(conn_a):
# step_categories — platform visibility # step_categories — platform visibility
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_step_categories_account_a_cannot_see_account_b(conn_a): async def test_step_categories_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch( rows = await conn_a.fetch(
f"SELECT id FROM step_categories WHERE account_id = '{ACCOUNT_B_ID}'" f"SELECT id FROM step_categories WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -246,7 +266,6 @@ async def test_step_categories_account_a_cannot_see_account_b(conn_a):
# psa_connections — tenant-only # psa_connections — tenant-only
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_psa_connections_account_a_cannot_see_account_b(conn_a): async def test_psa_connections_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch( rows = await conn_a.fetch(
f"SELECT id FROM psa_connections WHERE account_id = '{ACCOUNT_B_ID}'" f"SELECT id FROM psa_connections WHERE account_id = '{ACCOUNT_B_ID}'"
@@ -258,9 +277,782 @@ async def test_psa_connections_account_a_cannot_see_account_b(conn_a):
# flow_proposals — tenant-only # flow_proposals — tenant-only
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_flow_proposals_account_a_cannot_see_account_b(conn_a): async def test_flow_proposals_account_a_cannot_see_account_b(conn_a):
rows = await conn_a.fetch( rows = await conn_a.fetch(
f"SELECT id FROM flow_proposals WHERE account_id = '{ACCOUNT_B_ID}'" f"SELECT id FROM flow_proposals WHERE account_id = '{ACCOUNT_B_ID}'"
) )
assert len(rows) == 0 assert len(rows) == 0
# ---------------------------------------------------------------------------
# Phase 2 fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
async def session_row_ids(admin_conn):
"""
Insert one `sessions` row and one `ai_sessions` row for each of
ACCOUNT_A and ACCOUNT_B using the superuser connection (BYPASSRLS).
Returns a dict with the inserted IDs for use in tests.
Cleans up on exit.
"""
# Resolve a valid tree_id and user_id for each account
tree_a = await admin_conn.fetchrow(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_A_ID}' LIMIT 1"
)
tree_b = await admin_conn.fetchrow(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}' LIMIT 1"
)
user_a = await admin_conn.fetchrow(
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_A_ID}' LIMIT 1"
)
user_b = await admin_conn.fetchrow(
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_B_ID}' LIMIT 1"
)
assert tree_a is not None, f"No tree found for ACCOUNT_A ({ACCOUNT_A_ID}) — seed_rls_test_data must run first"
assert tree_b is not None, f"No tree found for ACCOUNT_B ({ACCOUNT_B_ID}) — seed_rls_test_data must run first"
assert user_a is not None, f"No user found for ACCOUNT_A ({ACCOUNT_A_ID}) — seed_rls_test_data must run first"
assert user_b is not None, f"No user found for ACCOUNT_B ({ACCOUNT_B_ID}) — seed_rls_test_data must run first"
tree_a_id = str(tree_a["id"])
tree_b_id = str(tree_b["id"])
user_a_id = str(user_a["id"])
user_b_id = str(user_b["id"])
session_a_id = str(uuid.uuid4())
session_b_id = str(uuid.uuid4())
ai_session_a_id = str(uuid.uuid4())
ai_session_b_id = str(uuid.uuid4())
# Insert sessions rows (sessions uses started_at not created_at)
await admin_conn.execute(f"""
INSERT INTO sessions (
id, tree_id, user_id, account_id, tree_snapshot,
path_taken, decisions, custom_steps, started_at
) VALUES
('{session_a_id}', '{tree_a_id}', '{user_a_id}', '{ACCOUNT_A_ID}',
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW()),
('{session_b_id}', '{tree_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW())
""")
# Insert ai_sessions rows
# confidence_tier valid values: 'guided' | 'exploring' | 'discovery'
await admin_conn.execute(f"""
INSERT INTO ai_sessions (
id, user_id, account_id, session_type, intake_type,
intake_content, status, confidence_tier, confidence_score,
created_at, updated_at
) VALUES
('{ai_session_a_id}', '{user_a_id}', '{ACCOUNT_A_ID}',
'guided', 'free_text', '{{}}'::jsonb, 'active', 'guided', 0.0,
NOW(), NOW()),
('{ai_session_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'guided', 'free_text', '{{}}'::jsonb, 'active', 'guided', 0.0,
NOW(), NOW())
""")
# -------------------------------------------------------------------------
# Seed Account B rows for every "cannot-see" table that would otherwise be
# empty. Without these, isolation tests pass vacuously even when RLS is off.
# -------------------------------------------------------------------------
# session_branches (FK: ai_sessions.id)
branch_b_row = await admin_conn.fetchrow("""
INSERT INTO session_branches (
id, session_id, account_id, branch_order, label, status,
conversation_messages, created_at, updated_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, 1, 'test-branch', 'active',
'[]'::jsonb, NOW(), NOW()
) RETURNING id
""", ai_session_b_id, ACCOUNT_B_ID)
branch_b_id = str(branch_b_row["id"])
# session_supporting_data (FK: sessions.id)
supporting_data_b_row = await admin_conn.fetchrow("""
INSERT INTO session_supporting_data (
id, session_id, account_id, label, data_type, content,
sort_order, created_at, updated_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, 'test-data', 'text_snippet',
'test content', 0, NOW(), NOW()
) RETURNING id
""", session_b_id, ACCOUNT_B_ID)
supporting_data_b_id = str(supporting_data_b_row["id"])
# session_resolution_outputs (FK: ai_sessions.id)
resolution_output_b_row = await admin_conn.fetchrow("""
INSERT INTO session_resolution_outputs (
id, session_id, account_id, output_type, generated_content,
status, generated_by_model, created_at, updated_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, 'psa_ticket_notes',
'test content', 'draft', 'test-model', NOW(), NOW()
) RETURNING id
""", ai_session_b_id, ACCOUNT_B_ID)
resolution_output_b_id = str(resolution_output_b_row["id"])
# session_handoffs (FK: ai_sessions.id, users.id)
handoff_b_row = await admin_conn.fetchrow("""
INSERT INTO session_handoffs (
id, session_id, account_id, handed_off_by, intent, snapshot,
priority, psa_note_pushed, notification_sent, created_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, $3::uuid, 'park',
'{}'::jsonb, 'normal', false, false, NOW()
) RETURNING id
""", ai_session_b_id, ACCOUNT_B_ID, user_b_id)
handoff_b_id = str(handoff_b_row["id"])
# maintenance_schedules (FK: trees.id)
maintenance_b_row = await admin_conn.fetchrow("""
INSERT INTO maintenance_schedules (
id, tree_id, account_id, cron_expression, timezone,
created_at, updated_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, '0 9 * * 1', 'UTC',
NOW(), NOW()
) RETURNING id
""", tree_b_id, ACCOUNT_B_ID)
maintenance_b_id = str(maintenance_b_row["id"])
# psa_post_log (FK: ai_sessions.id, users.id)
psa_log_b_row = await admin_conn.fetchrow("""
INSERT INTO psa_post_log (
id, ai_session_id, account_id, ticket_id, note_type,
content_posted, status, posted_by, posted_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, 'TEST-0001', 'internal',
'test note', 'success', $3::uuid, NOW()
) RETURNING id
""", ai_session_b_id, ACCOUNT_B_ID, user_b_id)
psa_log_b_id = str(psa_log_b_row["id"])
# script_templates requires a script_categories row — insert a temporary one
script_category_b_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO script_categories (id, name, slug, sort_order, is_active, created_at, updated_at)
VALUES ('{script_category_b_id}', 'RLS Test Category', 'rls-test-category-{script_category_b_id[:8]}',
0, true, NOW(), NOW())
""")
script_template_b_row = await admin_conn.fetchrow(f"""
INSERT INTO script_templates (
id, category_id, account_id, name, slug, script_body,
complexity, is_active, created_at, updated_at
) VALUES (
gen_random_uuid(), '{script_category_b_id}'::uuid, $1::uuid,
'RLS Test Template', 'rls-test-template-b-' || gen_random_uuid()::text,
'Write-Host "test"', 'beginner', true, NOW(), NOW()
) RETURNING id
""", ACCOUNT_B_ID)
script_template_b_id = str(script_template_b_row["id"])
# script_generations (FK: script_templates.id, users.id)
script_gen_b_row = await admin_conn.fetchrow("""
INSERT INTO script_generations (
id, template_id, user_id, account_id, parameters_used,
generated_script, created_at
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, $3::uuid, '{}'::jsonb,
'test script', NOW()
) RETURNING id
""", script_template_b_id, user_b_id, ACCOUNT_B_ID)
script_gen_b_id = str(script_gen_b_row["id"])
try:
yield {
"session_a": session_a_id,
"session_b": session_b_id,
"ai_session_a": ai_session_a_id,
"ai_session_b": ai_session_b_id,
}
finally:
# Cleanup in reverse FK order (children before parents)
await admin_conn.execute(
f"DELETE FROM script_generations WHERE id = '{script_gen_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM session_branches WHERE id = '{branch_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM session_supporting_data WHERE id = '{supporting_data_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM session_resolution_outputs WHERE id = '{resolution_output_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM session_handoffs WHERE id = '{handoff_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM maintenance_schedules WHERE id = '{maintenance_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM psa_post_log WHERE id = '{psa_log_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM script_templates WHERE id = '{script_template_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM script_categories WHERE id = '{script_category_b_id}'"
)
await admin_conn.execute(
f"DELETE FROM sessions WHERE id IN ('{session_a_id}', '{session_b_id}')"
)
await admin_conn.execute(
f"DELETE FROM ai_sessions WHERE id IN ('{ai_session_a_id}', '{ai_session_b_id}')"
)
# ---------------------------------------------------------------------------
# sessions
# ---------------------------------------------------------------------------
async def test_sessions_account_a_cannot_see_account_b_sessions(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM sessions WHERE id = '{session_row_ids['session_b']}'"
)
assert len(rows) == 0, "Account A should not see Account B sessions"
async def test_sessions_account_a_can_see_own_sessions(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM sessions WHERE id = '{session_row_ids['session_a']}'"
)
assert len(rows) == 1, "Account A should see its own sessions"
async def test_sessions_no_context_sees_nothing(conn_no_context, session_row_ids):
rows = await conn_no_context.fetch(
f"SELECT id FROM sessions WHERE id IN "
f"('{session_row_ids['session_a']}', '{session_row_ids['session_b']}')"
)
assert len(rows) == 0, "No-context connection should see no sessions"
# ---------------------------------------------------------------------------
# ai_sessions
# ---------------------------------------------------------------------------
async def test_ai_sessions_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM ai_sessions WHERE id = '{session_row_ids['ai_session_b']}'"
)
assert len(rows) == 0, "Account A should not see Account B ai_sessions"
async def test_ai_sessions_account_a_can_see_own(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM ai_sessions WHERE id = '{session_row_ids['ai_session_a']}'"
)
assert len(rows) == 1, "Account A should see its own ai_sessions"
# ---------------------------------------------------------------------------
# session_branches
# ---------------------------------------------------------------------------
async def test_session_branches_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM session_branches WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_branches"
# ---------------------------------------------------------------------------
# session_supporting_data
# ---------------------------------------------------------------------------
async def test_session_supporting_data_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM session_supporting_data WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_supporting_data"
# ---------------------------------------------------------------------------
# session_resolution_outputs
# ---------------------------------------------------------------------------
async def test_session_resolution_outputs_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM session_resolution_outputs WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_resolution_outputs"
# ---------------------------------------------------------------------------
# session_handoffs
# ---------------------------------------------------------------------------
async def test_session_handoffs_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM session_handoffs WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_handoffs"
# ---------------------------------------------------------------------------
# script_templates
# ---------------------------------------------------------------------------
async def test_script_templates_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM script_templates WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B script_templates"
# ---------------------------------------------------------------------------
# script_generations
# ---------------------------------------------------------------------------
async def test_script_generations_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM script_generations WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B script_generations"
# ---------------------------------------------------------------------------
# maintenance_schedules
# ---------------------------------------------------------------------------
async def test_maintenance_schedules_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM maintenance_schedules WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B maintenance_schedules"
# ---------------------------------------------------------------------------
# psa_post_log
# ---------------------------------------------------------------------------
async def test_psa_post_log_account_a_cannot_see_account_b(conn_a, session_row_ids):
rows = await conn_a.fetch(
f"SELECT id FROM psa_post_log WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B psa_post_log"
# ---------------------------------------------------------------------------
# step_library — visibility-aware policy
# ---------------------------------------------------------------------------
async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_conn, conn_a):
"""Private/non-public steps owned by Account B must not be visible to Account A."""
private_step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{private_step_id}', '{ACCOUNT_B_ID}', 'RLS Private Step', 'action',
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM step_library "
f"WHERE id = '{private_step_id}' AND visibility != 'public'"
)
assert len(rows) == 0, "Account A should not see Account B's private step_library rows"
finally:
await admin_conn.execute(
f"DELETE FROM step_library WHERE id = '{private_step_id}'"
)
async def test_step_library_account_a_can_see_account_b_public_steps(admin_conn, conn_a):
"""Public steps owned by Account B MUST be visible to Account A (cross-tenant visibility)."""
public_step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{public_step_id}', '{ACCOUNT_B_ID}', 'RLS Public Step', 'action',
'{{}}'::jsonb, 'public', TRUE, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM step_library WHERE id = '{public_step_id}'"
)
assert len(rows) == 1, (
"Account A should see public steps owned by Account B "
"(cross-tenant public visibility policy)"
)
finally:
await admin_conn.execute(
f"DELETE FROM step_library WHERE id = '{public_step_id}'"
)
# ===========================================================================
# Phase 3 RLS isolation tests
# Tables: step_ratings, step_usage_log, target_lists,
# session_shares, audit_logs, tree_shares
# ===========================================================================
# ---------------------------------------------------------------------------
# Helpers shared by Phase 3 fixtures
# ---------------------------------------------------------------------------
async def _get_user_b_id(admin_conn) -> str:
row = await admin_conn.fetchrow(
"SELECT id FROM users WHERE email = 'rls-user-b@example.com'"
)
return str(row["id"])
async def _get_tree_b_id(admin_conn) -> str:
row = await admin_conn.fetchrow(
f"SELECT id FROM trees WHERE account_id = '{ACCOUNT_B_ID}' LIMIT 1"
)
return str(row["id"])
# ---------------------------------------------------------------------------
# step_ratings
# ---------------------------------------------------------------------------
async def test_step_ratings_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see step ratings belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
# Need a step_library row as FK target
step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 RLS Step', 'action',
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
)
""")
rating_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_ratings (
id, step_id, user_id, account_id, is_verified_use, is_visible,
created_at, updated_at
) VALUES (
'{rating_id}', '{step_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
FALSE, TRUE, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM step_ratings WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B step_ratings"
finally:
await admin_conn.execute(f"DELETE FROM step_ratings WHERE id = '{rating_id}'")
await admin_conn.execute(f"DELETE FROM step_library WHERE id = '{step_id}'")
# ---------------------------------------------------------------------------
# step_usage_log
# ---------------------------------------------------------------------------
async def test_step_usage_log_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see step usage logs belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tree_b_id = await _get_tree_b_id(admin_conn)
step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 Usage Step', 'action',
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
)
""")
# Need a sessions row as FK for usage log
session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO sessions (
id, tree_id, user_id, account_id, tree_snapshot,
path_taken, decisions, custom_steps, started_at
) VALUES (
'{session_id}', '{tree_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW()
)
""")
log_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_usage_log (
id, step_id, user_id, account_id, session_id, used_at
) VALUES (
'{log_id}', '{step_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'{session_id}', NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM step_usage_log WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B step_usage_log"
finally:
await admin_conn.execute(f"DELETE FROM step_usage_log WHERE id = '{log_id}'")
await admin_conn.execute(f"DELETE FROM sessions WHERE id = '{session_id}'")
await admin_conn.execute(f"DELETE FROM step_library WHERE id = '{step_id}'")
# ---------------------------------------------------------------------------
# target_lists
# ---------------------------------------------------------------------------
async def test_target_lists_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see target lists belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tl_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO target_lists (
id, account_id, created_by, name, targets, created_at, updated_at
) VALUES (
'{tl_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
'Phase3 RLS Target List', '[]'::jsonb, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM target_lists WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B target_lists"
finally:
await admin_conn.execute(f"DELETE FROM target_lists WHERE id = '{tl_id}'")
# ---------------------------------------------------------------------------
# session_shares
# ---------------------------------------------------------------------------
async def test_session_shares_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see session shares belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tree_b_id = await _get_tree_b_id(admin_conn)
# Need a sessions row as FK
session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO sessions (
id, tree_id, user_id, account_id, tree_snapshot,
path_taken, decisions, custom_steps, started_at
) VALUES (
'{session_id}', '{tree_b_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NOW()
)
""")
share_id = str(uuid.uuid4())
share_token = f"phase3-rls-test-{share_id[:8]}"
await admin_conn.execute(f"""
INSERT INTO session_shares (
id, session_id, account_id, share_token, visibility,
created_by, view_count, is_active, created_at, updated_at
) VALUES (
'{share_id}', '{session_id}', '{ACCOUNT_B_ID}',
'{share_token}', 'account', '{user_b_id}',
0, TRUE, NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM session_shares WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B session_shares"
finally:
await admin_conn.execute(f"DELETE FROM session_shares WHERE id = '{share_id}'")
await admin_conn.execute(f"DELETE FROM sessions WHERE id = '{session_id}'")
# ---------------------------------------------------------------------------
# audit_logs
# ---------------------------------------------------------------------------
async def test_audit_logs_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see audit logs belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
log_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO audit_logs (
id, user_id, account_id, action, resource_type, created_at
) VALUES (
'{log_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'test.action', 'test_resource', NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM audit_logs WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B audit_logs"
finally:
await admin_conn.execute(f"DELETE FROM audit_logs WHERE id = '{log_id}'")
# ---------------------------------------------------------------------------
# tree_shares
# ---------------------------------------------------------------------------
async def test_tree_shares_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see tree shares belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tree_b_id = await _get_tree_b_id(admin_conn)
share_id = str(uuid.uuid4())
share_token = f"phase3-tree-rls-{share_id[:8]}"
await admin_conn.execute(f"""
INSERT INTO tree_shares (
id, tree_id, account_id, share_token, created_by,
allow_forking, created_at
) VALUES (
'{share_id}', '{tree_b_id}', '{ACCOUNT_B_ID}',
'{share_token}', '{user_b_id}', TRUE, NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM tree_shares WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B tree_shares"
finally:
await admin_conn.execute(f"DELETE FROM tree_shares WHERE id = '{share_id}'")
# ===========================================================================
# Phase 4 RLS isolation tests
# Tables: users, script_builder_sessions, ai_session_steps, notifications
#
# Note: platform_steps and template_trees have no account_id column and no RLS —
# they are globally readable by all authenticated users.
# ===========================================================================
# ---------------------------------------------------------------------------
# users
# ---------------------------------------------------------------------------
async def test_users_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see users belonging to Account B."""
rows = await conn_a.fetch(
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B users"
async def test_users_account_a_can_see_own(admin_conn, conn_a):
"""Account A must be able to see its own users."""
rows = await conn_a.fetch(
f"SELECT id FROM users WHERE account_id = '{ACCOUNT_A_ID}'"
)
assert len(rows) > 0, "Account A should see its own users"
# ---------------------------------------------------------------------------
# script_builder_sessions
# ---------------------------------------------------------------------------
async def test_script_builder_sessions_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see script builder sessions belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO script_builder_sessions (
id, user_id, account_id, language, created_at, updated_at
) VALUES (
'{session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'powershell', NOW(), NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM script_builder_sessions WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B script_builder_sessions"
finally:
await admin_conn.execute(
f"DELETE FROM script_builder_sessions WHERE id = '{session_id}'"
)
# ---------------------------------------------------------------------------
# ai_session_steps
# ---------------------------------------------------------------------------
async def test_ai_session_steps_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see ai_session_steps belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
tree_b_id = await _get_tree_b_id(admin_conn)
# Need an ai_sessions row as FK
ai_session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO ai_sessions (
id, user_id, account_id, flow_type, status, confidence_tier,
created_at, updated_at
) VALUES (
'{ai_session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'troubleshooting', 'active', 'guided', NOW(), NOW()
)
""")
step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO ai_session_steps (
id, session_id, account_id, step_type, content,
created_at
) VALUES (
'{step_id}', '{ai_session_id}', '{ACCOUNT_B_ID}',
'question', 'Phase4 RLS test step', NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM ai_session_steps WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B ai_session_steps"
finally:
await admin_conn.execute(f"DELETE FROM ai_session_steps WHERE id = '{step_id}'")
await admin_conn.execute(f"DELETE FROM ai_sessions WHERE id = '{ai_session_id}'")
# ---------------------------------------------------------------------------
# notifications
# ---------------------------------------------------------------------------
async def test_notifications_account_a_cannot_see_account_b(admin_conn, conn_a):
"""Account A must not see notifications belonging to Account B."""
user_b_id = await _get_user_b_id(admin_conn)
notif_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO notifications (
id, user_id, account_id, type, title, message,
is_read, created_at
) VALUES (
'{notif_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'info', 'Phase4 RLS Test', 'RLS isolation test notification',
FALSE, NOW()
)
""")
try:
rows = await conn_a.fetch(
f"SELECT id FROM notifications WHERE account_id = '{ACCOUNT_B_ID}'"
)
assert len(rows) == 0, "Account A should not see Account B notifications"
finally:
await admin_conn.execute(f"DELETE FROM notifications WHERE id = '{notif_id}'")

View File

@@ -155,6 +155,7 @@ class TestSaveSessionAsTreeAPI:
session = Session( session = Session(
tree_id=tree.id, tree_id=tree.id,
user_id=UUID(test_user["user_data"]["id"]), user_id=UUID(test_user["user_data"]["id"]),
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure, tree_snapshot=tree.tree_structure,
path_taken=["root"], path_taken=["root"],
decisions=[{"node_id": "root", "timestamp": datetime.now(timezone.utc).isoformat()}], decisions=[{"node_id": "root", "timestamp": datetime.now(timezone.utc).isoformat()}],
@@ -199,6 +200,7 @@ class TestSaveSessionAsTreeAPI:
session = Session( session = Session(
tree_id=tree.id, tree_id=tree.id,
user_id=UUID(test_user["user_data"]["id"]), user_id=UUID(test_user["user_data"]["id"]),
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure, tree_snapshot=tree.tree_structure,
path_taken=["root"], path_taken=["root"],
decisions=[], decisions=[],
@@ -239,6 +241,7 @@ class TestSaveSessionAsTreeAPI:
session = Session( session = Session(
tree_id=tree.id, tree_id=tree.id,
user_id=UUID(test_user["user_data"]["id"]), user_id=UUID(test_user["user_data"]["id"]),
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure, tree_snapshot=tree.tree_structure,
path_taken=["root"], path_taken=["root"],
decisions=[], decisions=[],
@@ -279,6 +282,7 @@ class TestSaveSessionAsTreeAPI:
session = Session( session = Session(
tree_id=tree.id, tree_id=tree.id,
user_id=UUID(test_user["user_data"]["id"]), user_id=UUID(test_user["user_data"]["id"]),
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure, tree_snapshot=tree.tree_structure,
path_taken=["root"], path_taken=["root"],
decisions=[], decisions=[],
@@ -352,6 +356,7 @@ class TestSaveSessionAsTreeAPI:
session = Session( session = Session(
tree_id=tree.id, tree_id=tree.id,
user_id=other_user.id, user_id=other_user.id,
account_id=UUID(test_user["user_data"]["account_id"]),
tree_snapshot=tree.tree_structure, tree_snapshot=tree.tree_structure,
path_taken=["root"], path_taken=["root"],
decisions=[], decisions=[],

View File

@@ -0,0 +1,89 @@
import pytest
from sqlalchemy import select
from app.core import service_account as service_account_module
from app.core.service_account import (
SERVICE_ACCOUNT_EMAIL,
SYSTEM_ACCOUNT_DISPLAY_CODE,
ensure_service_account,
)
from app.models.account import Account
from app.models.user import User
class _SessionFactoryOverride:
def __init__(self, session):
self._session = session
def __call__(self):
return self
async def __aenter__(self):
return self._session
async def __aexit__(self, exc_type, exc, tb):
return False
@pytest.mark.asyncio
async def test_ensure_service_account_creates_and_reuses_seeded_user(test_db, monkeypatch):
monkeypatch.setattr(
service_account_module,
"_admin_session_factory",
_SessionFactoryOverride(test_db),
)
service_account_id = await ensure_service_account(test_db)
created_user = (
await test_db.execute(select(User).where(User.id == service_account_id))
).scalar_one()
assert created_user.email == SERVICE_ACCOUNT_EMAIL
assert created_user.is_service_account is True
system_account = (
await test_db.execute(
select(Account).where(Account.display_code == SYSTEM_ACCOUNT_DISPLAY_CODE)
)
).scalar_one()
assert created_user.account_id == system_account.id
second_id = await ensure_service_account(test_db)
assert second_id == service_account_id
@pytest.mark.asyncio
async def test_ensure_service_account_marks_existing_user_as_service_account(test_db, monkeypatch):
monkeypatch.setattr(
service_account_module,
"_admin_session_factory",
_SessionFactoryOverride(test_db),
)
system_account = (
await test_db.execute(
select(Account).where(Account.display_code == SYSTEM_ACCOUNT_DISPLAY_CODE)
)
).scalar_one()
existing_user = User(
email=SERVICE_ACCOUNT_EMAIL,
name="ResolutionFlow",
password_hash="!service-account-no-login",
role="engineer",
is_super_admin=False,
is_team_admin=False,
is_active=True,
is_service_account=False,
must_change_password=False,
account_id=system_account.id,
account_role="engineer",
)
test_db.add(existing_user)
await test_db.commit()
resolved_id = await ensure_service_account(test_db)
await test_db.refresh(existing_user)
assert resolved_id == existing_user.id
assert existing_user.is_service_account is True

View File

@@ -3,37 +3,10 @@ import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.models.user import User from app.models.user import User
from sqlalchemy import select from sqlalchemy import select
@pytest.fixture
async def auth_headers(client: AsyncClient, test_db: AsyncSession, test_user: dict):
"""Override auth_headers to ensure the test user has a team_id assigned."""
# Fetch the user from DB and assign a team
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
user = result.scalar_one()
# Create a team and assign the user to it
team = Team(name="Test Team")
test_db.add(team)
await test_db.flush()
user.team_id = team.id
await test_db.commit()
# Re-login to get a fresh token
login_data = {
"email": test_user["email"],
"password": test_user["password"],
}
resp = await client.post("/api/v1/auth/login/json", json=login_data)
assert resp.status_code == 200
token_data = resp.json()
return {"Authorization": f"Bearer {token_data['access_token']}"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_target_list(client: AsyncClient, auth_headers: dict): async def test_create_target_list(client: AsyncClient, auth_headers: dict):
resp = await client.post( resp = await client.post(
@@ -107,25 +80,28 @@ async def test_delete_target_list(client: AsyncClient, auth_headers: dict):
assert get.status_code == 404 assert get.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers: dict, test_db): async def test_cannot_access_other_accounts_list(client: AsyncClient, auth_headers: dict, test_db):
"""User from team B cannot access team A's list.""" """User from account B cannot access account A's target list."""
import uuid import uuid
from app.models.team import Team from app.models.account import Account
from app.models.user import User from app.models.user import User
from app.core.security import get_password_hash from app.core.security import get_password_hash
# Create team A list using existing auth_headers # Create account A list using existing auth_headers
create = await client.post( create = await client.post(
"/api/v1/target-lists/", "/api/v1/target-lists/",
json={"name": "Team A List", "targets": [{"label": "SRV-A"}]}, json={"name": "Account A List", "targets": [{"label": "SRV-A"}]},
headers=auth_headers, headers=auth_headers,
) )
assert create.status_code == 201 assert create.status_code == 201
list_id = create.json()["id"] list_id = create.json()["id"]
# Create a separate team B with its own user # Create a separate account B with its own user
team_b = Team(name=f"Team B {uuid.uuid4()}") account_b = Account(
test_db.add(team_b) name=f"Account B {uuid.uuid4()}",
display_code=f"AB{str(uuid.uuid4())[:6].upper()}",
)
test_db.add(account_b)
await test_db.flush() await test_db.flush()
user_b = User( user_b = User(
@@ -133,11 +109,13 @@ async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers:
password_hash=get_password_hash("password123"), password_hash=get_password_hash("password123"),
name="User B", name="User B",
is_active=True, is_active=True,
team_id=team_b.id, account_id=account_b.id,
account_role="engineer",
role="engineer", role="engineer",
) )
test_db.add(user_b) test_db.add(user_b)
await test_db.flush() await test_db.flush()
await test_db.commit()
# Get auth token for user B # Get auth token for user B
login = await client.post( login = await client.post(
@@ -148,6 +126,6 @@ async def test_cannot_access_other_teams_list(client: AsyncClient, auth_headers:
token_b = login.json()["access_token"] token_b = login.json()["access_token"]
headers_b = {"Authorization": f"Bearer {token_b}"} headers_b = {"Authorization": f"Bearer {token_b}"}
# Team B cannot access Team A's list # Account B cannot access Account A's list
resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=headers_b) resp = await client.get(f"/api/v1/target-lists/{list_id}", headers=headers_b)
assert resp.status_code == 404 assert resp.status_code == 404

View File

@@ -117,6 +117,7 @@ class TestTreeSharing:
for i in range(3): for i in range(3):
share = TreeShare( share = TreeShare(
tree_id=sample_tree.id, tree_id=sample_tree.id,
account_id=sample_tree.account_id,
share_token=f"token_{i}_" + "x" * 56, share_token=f"token_{i}_" + "x" * 56,
created_by=sample_tree.author_id, created_by=sample_tree.author_id,
allow_forking=i % 2 == 0 allow_forking=i % 2 == 0
@@ -162,6 +163,7 @@ class TestTreeSharing:
# Create a share # Create a share
share = TreeShare( share = TreeShare(
tree_id=sample_tree.id, tree_id=sample_tree.id,
account_id=sample_tree.account_id,
share_token="public_test_token" + "x" * 47, share_token="public_test_token" + "x" * 47,
created_by=UUID(test_user["user_data"]["id"]), created_by=UUID(test_user["user_data"]["id"]),
allow_forking=True allow_forking=True
@@ -192,6 +194,7 @@ class TestTreeSharing:
# Create expired share # Create expired share
share = TreeShare( share = TreeShare(
tree_id=sample_tree.id, tree_id=sample_tree.id,
account_id=sample_tree.account_id,
share_token="expired_token" + "x" * 50, share_token="expired_token" + "x" * 50,
created_by=UUID(test_user["user_data"]["id"]), created_by=UUID(test_user["user_data"]["id"]),
allow_forking=True, allow_forking=True,
@@ -209,6 +212,7 @@ class TestTreeSharing:
from uuid import UUID from uuid import UUID
share = TreeShare( share = TreeShare(
tree_id=sample_tree.id, tree_id=sample_tree.id,
account_id=sample_tree.account_id,
share_token="inactive_tree_token" + "x" * 44, share_token="inactive_tree_token" + "x" * 44,
created_by=UUID(test_user["user_data"]["id"]), created_by=UUID(test_user["user_data"]["id"]),
allow_forking=True allow_forking=True
@@ -248,6 +252,37 @@ class TestTreeSharing:
tokens.add(token) tokens.add(token)
assert len(tokens) == 5 assert len(tokens) == 5
async def test_share_account_id_matches_tree_not_actor(
self, client: AsyncClient, sample_tree, auth_headers, test_db
):
"""Share account_id must equal tree.account_id, not the actor's account_id.
A super admin in a different account can share any tree. The resulting
TreeShare row must live in the tree-owner's account so that the tree
owner's RLS context covers it. If account_id were derived from the
actor instead, the share would vanish from the tree owner's view once
RLS is enabled.
"""
from uuid import UUID
from sqlalchemy import select
response = await client.post(
f"/api/v1/trees/{sample_tree.id}/share",
json={"allow_forking": True},
headers=auth_headers,
)
assert response.status_code == 201
share_token = response.json()["share_token"]
result = await test_db.execute(
select(TreeShare).where(TreeShare.share_token == share_token)
)
share = result.scalar_one()
assert share.account_id == sample_tree.account_id, (
"TreeShare.account_id must equal tree.account_id, not the actor's account. "
"Shares must live in the tree owner's tenant for RLS to cover them."
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_migration_defaults_visibility_to_team(test_db): async def test_migration_defaults_visibility_to_team(test_db):

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

@@ -0,0 +1,757 @@
# Network Diagram Editor — Draw.io-Style Implementation Document
> **Date:** 2026-04-13
> **Status:** Proposed
> **Audience:** Product, frontend, backend, and agentic workers
> **Goal:** Build a production-grade network diagram editor inside ResolutionFlow that feels close to draw.io while staying MSP- and topology-focused.
---
## Executive Summary
ResolutionFlow should implement network diagrams as a first-class editor surface, not as a lightweight canvas utility. The right target is not "clone draw.io exactly," but "deliver draw.io-grade editing quality for MSP network topology work."
The recommended path is:
1. **Use the existing network-diagram branch architecture as the foundation**
2. **Ship the already-proven CRUD/editor shell as Phase 1**
3. **Invest the next phases in interaction quality, editor commands, and interoperability**
4. **Keep a ResolutionFlow-native JSON schema as the source of truth**
5. **Add draw.io compatibility at import/export boundaries, not at the storage layer**
This preserves delivery speed while giving the product room to grow into a robust diagramming tool.
---
## Product Goal
Build a network diagram creation tool that:
- Feels familiar to users who already know draw.io
- Supports MSP workflows better than a generic diagramming app
- Makes manual editing fast and safe
- Supports AI-assisted generation and clean-up without depending on AI for correctness
- Fits ResolutionFlow's existing frontend/backend architecture cleanly
Success is not measured by raw feature count alone. Success means a user can open the editor and confidently:
- Create a clean network map from scratch
- Drag devices from a stencil palette onto a canvas
- Connect, label, group, align, copy, duplicate, and organize elements quickly
- Save and revisit diagrams safely
- Export the result for documentation and client communication
---
## Existing Repo Context
ResolutionFlow already has strong signals for this direction:
- The main architecture is React 19 + Vite + TypeScript on the frontend, FastAPI + PostgreSQL on the backend.
- There are existing design and plan docs for network diagrams:
- `docs/superpowers/specs/2026-04-04-react-flow-ui-network-diagrams-design.md`
- `docs/superpowers/specs/2026-04-04-network-diagram-ux-improvements-design.md`
- `docs/superpowers/plans/2026-04-04-react-flow-ui-network-diagrams.md`
- `docs/superpowers/plans/2026-04-04-network-diagram-ux-improvements.md`
- Git history shows a substantial prior implementation on `feat/network-map-builder-prod`.
That branch already included:
- Backend model, schema, migration, API routes, and AI generation service
- Frontend list page and editor page
- React Flow-based canvas
- Device registry and custom node types
- Context menu, copy/paste/duplicate shortcuts, and drag/drop improvements
- Inspector/properties panel
- Import/export JSON
This is important: **the best implementation path is to revive and harden that architecture, not to invent a parallel one.**
---
## Non-Goals
The first implementation should not try to become a full whiteboard platform.
Out of scope for the initial milestone:
- Real-time multiplayer collaboration
- Comments/presence/cursors
- Arbitrary slide decks or presentation features
- BPMN/UML/general enterprise diagram libraries
- Full draw.io parity on day one
- Replacing ResolutionFlow's tree editor architecture
The editor should stay focused on network topology and MSP documentation use cases.
---
## User Experience Target
The editor should feel close to draw.io in the following ways:
- Fast drag/drop from a left stencil panel
- Predictable selection behavior
- Context menus and keyboard shortcuts
- Snap-to-grid and alignment affordances
- Resizable groups and containers
- Good edge routing options
- Easy text and label editing
- Familiar import/export workflows
The editor should exceed draw.io in MSP-specific workflows:
- Device types that reflect real client environments
- AI-generated starting diagrams from text descriptions
- Client and asset metadata
- Future hooks into PSA, assets, tickets, and documentation
---
## Recommended Architecture
### Frontend
Use a dedicated feature area:
- `frontend/src/pages/NetworkDiagrams/`
- `frontend/src/components/network/`
- `frontend/src/api/networkDiagrams.ts`
- `frontend/src/types/network-diagram.ts`
Use React Flow as the canvas/rendering engine.
Why React Flow:
- Already used conceptually in the codebase
- Strong node/edge rendering model
- Good selection/dragging/viewport primitives
- Enough flexibility for custom nodes, groups, and edge styles
- Faster path to production than building custom canvas behavior from scratch
### Backend
Use a document-style data model stored in PostgreSQL JSONB:
- `network_diagrams` table
- JSONB `nodes`
- JSONB `edges`
- Metadata columns for account scoping, names, timestamps, archive state
Why document storage:
- Flexible schema evolution
- Fast implementation
- Simple import/export
- Easy autosave
- Works well with editor-state persistence
### Source of Truth
Use a ResolutionFlow-native schema as the system of record.
Do not store draw.io XML as the primary database format.
Instead:
- Store native JSON internally
- Import from draw.io into native JSON
- Export native JSON to draw.io-compatible XML when needed
This keeps the application decoupled from an external vendor format.
---
## Recommended File Layout
### Frontend
- `frontend/src/pages/NetworkDiagrams/index.tsx`
- Diagram list page
- `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx`
- Editor orchestration layer
- `frontend/src/components/network/NetworkCanvas.tsx`
- React Flow wrapper and viewport surface
- `frontend/src/components/network/DiagramHeader.tsx`
- Save state, title, metadata actions, export/import controls
- `frontend/src/components/network/ContextMenu.tsx`
- Node/canvas context menus
- `frontend/src/components/network/CanvasEmptyPrompt.tsx`
- Empty-state guidance
- `frontend/src/components/network/panels/DeviceToolbar.tsx`
- Stencil palette and searchable device library
- `frontend/src/components/network/panels/PropertiesPanel.tsx`
- Inspector for node/edge editing
- `frontend/src/components/network/panels/AIAssistPanel.tsx`
- AI generate/merge UX
- `frontend/src/components/network/hooks/useCanvasShortcuts.ts`
- Keyboard shortcuts and clipboard behavior
- `frontend/src/components/network/hooks/useDiagramCommands.ts`
- Shared command layer for actions invoked by keyboard, menus, and toolbar
- `frontend/src/components/network/nodes/*`
- Node components, registry, types, and render configuration
- `frontend/src/components/network/edges/*`
- Edge components and routing styles
- `frontend/src/api/networkDiagrams.ts`
- CRUD, import/export, AI generation, duplication
- `frontend/src/types/network-diagram.ts`
- Shared client-side typing
### Backend
- `backend/app/models/network_diagram.py`
- SQLAlchemy model
- `backend/app/schemas/network_diagram.py`
- Pydantic request/response models
- `backend/app/api/endpoints/network_diagrams.py`
- CRUD + import/export + AI routes
- `backend/app/services/network_diagram_ai_service.py`
- AI generation and later AI clean-up/layout assistance
- `backend/alembic/versions/074_add_network_diagrams_table.py`
- Initial schema migration
- `backend/app/models/network_diagram_version.py`
- Later addition for version history
- `backend/app/api/endpoints/network_diagram_versions.py`
- Later addition for restore/history flows
---
## Data Model
### V1 Database Model
The existing JSONB storage pattern is good for a first release:
- `id`
- `account_id`
- `name`
- `client_name`
- `asset_name`
- `description`
- `nodes` JSONB
- `edges` JSONB
- `thumbnail_url`
- `is_archived`
- `created_by`
- `created_at`
- `updated_at`
### Recommended Node Schema
Use a richer internal node shape than a minimal device-only object. The schema should be resilient enough to support future features without painful migration.
Recommended fields:
- `id`
- `kind`
- `device | group | text | shape | image`
- `type`
- device slug or shape subtype
- `label`
- `position`
- `size`
- `rotation`
- `zIndex`
- `parentId`
- `ports`
- `style`
- `data`
Examples:
- `kind=device`, `type=router`
- `kind=group`, `type=subnet`
- `kind=text`, `type=label`
### Recommended Edge Schema
- `id`
- `source`
- `target`
- `sourcePort`
- `targetPort`
- `label`
- `routing`
- `straight | step | orthogonal | curved`
- `waypoints`
- `style`
- `data`
### Why This Matters
The prior branch stored a solid but fairly lean `DiagramNode`/`DiagramEdge`. That is enough to start, but draw.io-like editing will need more than just `position`, `label`, and `connectionType`.
If we adopt the richer shape early, we reduce future rework in:
- manual bend-point support
- z-ordering
- groups/containers
- text/shape nodes
- port-specific connections
---
## Editor State Model
The editor should be built around four distinct layers of state:
### 1. Persisted Diagram State
What is saved to the backend:
- nodes
- edges
- metadata
### 2. Editor UI State
What lives only in the browser while editing:
- selected node IDs
- selected edge IDs
- open context menu
- active tool
- inspector visibility
- drag-over state
- clipboard reference
### 3. Derived View State
- filtered palette items
- current selection bounds
- whether paste is available
- whether align/distribute commands are valid
### 4. History State
- undo stack
- redo stack
- last autosave timestamp
The editor page should orchestrate these, but command logic should not be scattered across component trees.
---
## Command System
To make the tool feel like draw.io, add a shared command layer.
Recommended hook:
- `frontend/src/components/network/hooks/useDiagramCommands.ts`
This hook should expose commands like:
- `copySelection`
- `pasteSelection`
- `duplicateSelection`
- `deleteSelection`
- `selectAll`
- `fitView`
- `bringToFront`
- `sendToBack`
- `alignLeft`
- `alignCenter`
- `alignRight`
- `alignTop`
- `alignMiddle`
- `alignBottom`
- `distributeHorizontally`
- `distributeVertically`
- `groupSelection`
- `ungroupSelection`
- `lockSelection`
- `unlockSelection`
All of the following should call the same command functions:
- keyboard shortcuts
- toolbar buttons
- context menu items
- future command palette entries
This avoids duplicate logic and keeps behavior consistent.
---
## Phase-by-Phase Delivery Plan
## Phase 1 — Foundation MVP
### Objective
Ship a usable network diagram editor quickly using the existing branch shape.
### Scope
- Diagram list page
- Create/edit/archive/duplicate
- React Flow canvas
- Searchable device palette
- Device and group nodes
- Edge creation
- Properties panel
- Save + autosave
- Import/export ResolutionFlow JSON
- Basic AI generation from natural language
- Context menu
- Keyboard shortcuts:
- copy
- paste
- duplicate
- select all
- fit view
- delete
### Frontend Work
- Restore `NetworkDiagrams` page routes and navigation
- Restore `DiagramEditor`
- Restore `NetworkCanvas`
- Restore node/edge registries
- Restore clipboard + context menu behavior
- Add command-layer extraction if time allows
### Backend Work
- Restore migration, model, schemas, endpoints
- Validate account scoping and tenant isolation
- Restore import/export endpoint
- Restore AI generate endpoint
### Acceptance Criteria
- User can create and save a network map
- User can reopen it later
- User can drag devices from palette onto canvas
- User can connect nodes and label links
- User can copy/paste/duplicate/delete
- User can import/export JSON
- User can generate a starter diagram from text
---
## Phase 2 — Draw.io-Grade Editing Quality
### Objective
Close the biggest UX gap between a basic node editor and a real diagramming tool.
### Scope
- Snap-to-guides in addition to snap-to-grid
- Alignment commands
- Distribution commands
- Multi-select improvements
- Better z-order handling
- Inline text editing
- Better group/container behavior
- Rich edge routing choices
- Manual bend points
- Port-aware connection handling
- Keyboard nudging and modifier behavior
### New/Expanded Files
- `hooks/useDiagramCommands.ts`
- `hooks/useSelectionBounds.ts`
- `components/network/guides/*`
- `components/network/edges/*`
### Acceptance Criteria
- Multi-select editing feels reliable
- Align/distribute work predictably
- User can produce a polished topology without fighting the canvas
- Connectors can be shaped intentionally rather than only auto-routed
---
## Phase 3 — Interoperability and Export
### Objective
Let the editor fit real customer and internal documentation workflows.
### Scope
- SVG export
- PNG export
- PDF export
- Thumbnail generation
- Draw.io XML import
- Draw.io XML export
### Implementation Notes
Do not try to mirror every draw.io primitive internally.
Instead:
- Build a compatible subset for network maps
- Translate supported draw.io elements into native nodes/edges/groups/text
- Emit supported native diagrams back into draw.io XML
- Warn on unsupported constructs during import
### Acceptance Criteria
- A diagram can be exported for customer-facing documentation
- A supported draw.io network map can be imported into ResolutionFlow
- Users can move work between tools without losing essential topology content
---
## Phase 4 — ResolutionFlow-Native Differentiation
### Objective
Make this better than a generic diagram editor for MSP use cases.
### Scope
- AI merge into existing topology
- AI tidy-up / auto-layout refinement
- Asset-aware device metadata
- Client templates
- Common MSP topology starter kits
- Diagram-to-ticket or diagram-to-flow linking
- Version history and restore
### Acceptance Criteria
- AI helps users start faster and clean up faster
- Diagrams connect to the rest of the ResolutionFlow product
- Version history reduces fear of experimentation
---
## Draw.io Parity Matrix
| Capability | Priority | Notes |
|-----------|----------|-------|
| Drag/drop stencil palette | P0 | Must feel immediate and stable |
| Node resize/move/select | P0 | Core editor behavior |
| Edge creation and labeling | P0 | Core topology use case |
| Copy/paste/duplicate/delete | P0 | Expected baseline |
| Context menu + keyboard shortcuts | P0 | Must be fast and familiar |
| Snap-to-grid | P0 | Already supported directionally |
| Align/distribute | P1 | Big usability leap |
| Grouping/containers | P1 | Important for subnets, rooms, racks |
| Edge routing modes | P1 | Necessary for professional-looking diagrams |
| Inline text editing | P1 | Draw.io expectation |
| Layers/lock/hide | P2 | Useful once diagrams get large |
| Draw.io import/export | P2 | Important for migration and adoption |
| Realtime collaboration | P3 | Valuable, but not early priority |
---
## Risks and Mitigations
### Risk: Editor complexity balloons too quickly
**Mitigation**
- Keep the MVP narrow
- Use phased delivery
- Center everything around the command layer
### Risk: React Flow abstraction limits parity
**Mitigation**
- Validate manual bend points, grouping, and selection ergonomics early
- If a specific advanced behavior is awkward, implement it in a focused extension layer instead of abandoning React Flow entirely
### Risk: Import/export compatibility becomes a trap
**Mitigation**
- Support a documented subset of draw.io semantics
- Keep native JSON as the canonical internal model
- Warn clearly on unsupported import constructs
### Risk: AI-generated diagrams feel impressive but unreliable
**Mitigation**
- Treat AI output as a starting point only
- Keep editing UX first-class
- Make merge mode explicit and safe
### Risk: Users lose work through autosave/history gaps
**Mitigation**
- Add diagram versioning soon after MVP
- Preserve a local dirty-state guard
- Add explicit "saved at" feedback
---
## Versioning Recommendation
Version history should be planned early, even if shipped after the MVP.
Recommended table:
- `network_diagram_versions`
- `id`
- `diagram_id`
- `account_id`
- `snapshot` JSONB
- `created_by`
- `label`
- `created_at`
Recommended triggers for version creation:
- explicit "Save Version"
- before destructive import-replace
- before AI replace mode
- optionally every N minutes when dirty changes are substantial
This is one of the highest-leverage additions for user trust.
---
## Testing Strategy
### Frontend
- Unit-test command logic
- Unit-test serialization/deserialization
- Component-test context menu and shortcut behavior
- E2E test core editor flows:
- create diagram
- drag node
- connect nodes
- save and reload
- copy/paste
- import/export
### Backend
- API tests for CRUD
- API tests for tenant isolation
- API tests for import/export validation
- API tests for duplicate/archive
- AI endpoint tests with mocked provider output
### Manual QA
Required flows:
- New blank diagram
- Existing diagram edit
- Large diagram performance
- Multi-select behavior
- Keyboard shortcut guard behavior while inputs are focused
- Import malformed JSON
- AI merge into populated canvas
---
## Suggested Delivery Order
### Slice 1
- Restore backend migration/model/schema/router
- Restore types and API client
- Restore list page
### Slice 2
- Restore editor shell and canvas
- Restore nodes, edges, palette, save/load
### Slice 3
- Restore context menu, clipboard, shortcuts, inspector
- Validate dirty-state and autosave behavior
### Slice 4
- Restore AI generation and merge mode
- Tighten import/export UX
### Slice 5
- Implement command layer
- Add align/distribute/z-order polish
### Slice 6
- Add version history
- Add export polish and thumbnails
### Slice 7
- Add draw.io XML import/export subset
---
## Recommended Immediate Next Step
The best immediate implementation move is:
1. **Rebase or selectively port `feat/network-map-builder-prod` into the current codebase**
2. **Use that as the Phase 1 foundation**
3. **Do not start by rewriting the editor architecture**
That approach is faster, lower risk, and already aligned with the repo's documented direction.
---
## Worker Notes
If agentic workers implement this plan, they should:
- Reuse the existing network-diagram branch structure where possible
- Avoid introducing a second diagram architecture
- Keep native JSON as the canonical schema
- Treat command centralization as a priority, not an afterthought
- Ship MVP behavior first, then polish toward draw.io parity in focused slices
---
## Summary
ResolutionFlow can support a draw.io-like network diagram editor without fighting its current stack. The prior network-diagram branch already proves the right foundation:
- React Flow canvas
- FastAPI CRUD
- JSONB persistence
- device registry
- AI assist
- context menus
- keyboard shortcuts
- import/export
The real work now is not deciding whether to build it. The real work is:
- restoring that foundation cleanly,
- formalizing the internal schema,
- adding a reusable command system,
- and iterating on the editor interactions until the experience feels professional.
That path gives ResolutionFlow a practical, high-value network topology tool quickly, while preserving a credible route to near-draw.io quality over the next phases.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
# PSA Ticket Management — Design Spec
**Date:** 2026-04-16
**Status:** Approved
**Author:** Michael Chihlas + Claude
---
## Overview
Add PSA ticket management to ResolutionFlow so MSP engineers can view, manage, and create ConnectWise tickets without leaving the app. The feature surfaces in three places: a dedicated Tickets page, a dashboard widget on QuickStartPage, and a spin-off ticket flow inside ResolutionAssist sessions.
---
## Decisions Made
| Question | Decision |
|----------|----------|
| Where does ticket management live? | Both: dedicated `/tickets` page + dashboard widget on QuickStartPage |
| List layout | Flat list with rich filters + pagination |
| Row density | Compact single-line rows |
| Ticket detail | Right-side slide-out panel (~50% width) |
| Ticket creation | Two-tab modal: Quick Create (AI) + Full Form |
| Resource member list | All CW members, RF-mapped users visually highlighted |
| Architecture | Dedicated `ticket_service.py` + normalized DTOs |
---
## Section 1 — Backend
### New Endpoints
All added to `backend/app/api/endpoints/integrations.py`, backed by `backend/app/services/ticket_service.py`.
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/integrations/psa/tickets` | Create a ticket |
| `PATCH` | `/integrations/psa/tickets/{id}/status` | Update ticket status |
| `GET` | `/integrations/psa/tickets/{id}/resources` | List current assignees |
| `POST` | `/integrations/psa/tickets/{id}/resources` | Add a resource (member) |
| `DELETE` | `/integrations/psa/tickets/{id}/resources/{member_id}` | Remove a resource |
| `POST` | `/integrations/psa/tickets/ai-parse` | Natural language → structured pre-fill payload |
**Breaking change — `search_tickets` response shape updated to `TicketListResponse`.**
The existing `/integrations/psa/tickets/search` endpoint currently returns `list[PSATicketSearchResult]`. This spec changes it to return `TicketListResponse` (adds `total`, `page`, `page_size` wrapper).
Current callers that must be migrated:
- `integrationsApi.searchTickets()` in `frontend/src/api/integrations.ts` (line 18) — update return type
- `integrationsApi.searchTicketsQueue()` in `frontend/src/api/integrations.ts` (line 20) — update return type
- `frontend/src/components/dashboard/TicketQueue.tsx` — update to read `.items` from response
- `frontend/src/components/session/TicketPickerModal.tsx` — update to read `.items` from response
All other existing endpoints (`get_ticket`, `get_ticket_statuses`, `list_members`, `list_boards`) are unchanged.
### ticket_service.py
New service wrapping the PSA provider for ticket mutations. Keeps `integrations.py` clean and PSA-agnostic for future Autotask support.
Methods:
- `create_ticket(account_id, payload) → PSATicketCreated`
- `add_resource(account_id, ticket_id, member_id) → PSAResource`
- `remove_resource(account_id, ticket_id, member_id) → None`
- `update_status(account_id, ticket_id, status_id) → PSATicketStatusUpdate`
- `list_resources(account_id, ticket_id) → list[PSAResource]`
### PSA Provider — New Abstract Methods and Paginated Result Type
**New type in `backend/app/services/psa/types.py`:**
```python
@dataclass
class PaginatedTicketResult:
items: list[PSATicket]
total: int
page: int
page_size: int
```
**`search_tickets` signature change** — updated on both the abstract base and `ConnectWiseProvider` to return `PaginatedTicketResult` instead of `list[PSATicket]`:
```python
# base.py
@abstractmethod
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: ...
```
**How `total` is fetched** — ConnectWise provides `GET /service/tickets/count?conditions=...` which accepts the same conditions string as the page fetch. The `ConnectWiseProvider.search_tickets()` implementation fires two parallel requests:
1. `GET /service/tickets?conditions=...&pageSize=N&page=N` — the current page
2. `GET /service/tickets/count?conditions=...` — returns `{ "count": 142 }`
Both use the same built conditions string. `asyncio.gather()` runs them in parallel. The count result is used to populate `PaginatedTicketResult.total`.
**New abstract methods** added to `PSAProvider` base and `ConnectWiseProvider`:
```python
async def list_resources(self, ticket_id: int) -> list[PSAResource]: ...
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: ...
async def remove_resource(self, ticket_id: int, member_id: int) -> None: ...
async def create_ticket(self, payload: TicketCreatePayload) -> PSATicketCreated: ...
```
`update_status` already exists on the provider — no change needed there.
ConnectWise implementation:
- `list_resources``GET /service/tickets/{id}/members`
- `add_resource``POST /service/tickets/{id}/members`
- `remove_resource``DELETE /service/tickets/{id}/members/{member_id}`
- `create_ticket``POST /service/tickets`
### Normalized DTOs (Pydantic Schemas)
New schemas in `backend/app/schemas/psa_tickets.py`:
```python
class PSAResource(BaseModel):
member_id: int
member_name: str
member_identifier: str # CW username
is_rf_user: bool # True if mapped in RF member mappings
class PSATicketCreated(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResource]
class PSATicketStatusUpdate(BaseModel):
ticket_id: int
previous_status: str
new_status: str
class TicketCreatePayload(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class TicketListResponse(BaseModel):
items: list[PSATicketSearchResult] # existing schema
total: int
page: int
page_size: int
```
`search_tickets` endpoint updated to return `TicketListResponse` (was a plain list). Backend sorts results by `priority desc, dateEntered desc` via CW `orderBy` param.
### AI Parse Endpoint
`POST /integrations/psa/tickets/ai-parse`
Request:
```json
{ "prompt": "New ticket for Acme Corp, Outlook not syncing, high priority, assign to me" }
```
Response — all pre-fill fields nullable, explicit `missing_fields` and `warnings`:
```json
{
"summary": "Outlook not syncing",
"company_id": 42,
"board_id": null,
"priority_id": null,
"status_id": null,
"assigned_member_id": 17,
"description": "User reports Outlook calendar not syncing since yesterday morning.",
"missing_fields": ["board_id", "priority_id", "status_id"],
"warnings": ["Could not determine board from context"]
}
```
Frontend uses `missing_fields` to highlight required fields still needing engineer input. No ticket is created at this step — it is a parse-only endpoint.
---
## Section 2 — Frontend Architecture
### New Files
| File | Purpose |
|------|---------|
| `pages/TicketsPage.tsx` | Main tickets page — filter bar + paginated list |
| `components/tickets/TicketListRow.tsx` | Compact single-line row |
| `components/tickets/TicketFilterBar.tsx` | Config-driven filter bar (7 filters) |
| `components/tickets/TicketDetailPanel.tsx` | Slide-out panel orchestrator |
| `components/tickets/detail/TicketDetailHeader.tsx` | ID, summary, company, board, SLA |
| `components/tickets/detail/TicketResourceManager.tsx` | Assignee list + add/remove |
| `components/tickets/detail/TicketNotesFeed.tsx` | Chronological notes history |
| `components/tickets/detail/TicketAddNote.tsx` | Inline note composer |
| `components/tickets/detail/TicketConfigs.tsx` | Attached devices/configs |
| `components/tickets/detail/TicketRelated.tsx` | Related tickets list |
| `components/tickets/NewTicketModal.tsx` | Two-tab modal (owns draft state) |
| `components/tickets/AiTicketParseForm.tsx` | Prompt input → emits parsed values upward |
| `api/tickets.ts` | All ticket API calls (typed, `.then(r => r.data)` pattern) |
| `types/tickets.ts` | TypeScript interfaces mirroring normalized DTOs |
### Existing Files Touched
- `router.tsx` — add `/tickets` route (lazy, via `lazyWithRetry`)
- `AppLayout.tsx` — add "Tickets" nav item in sidebar under RESOLVE section
- `AssistantChatPage.tsx` — handle `create_spin_off_ticket` action type in TaskLane + add "New Ticket" button to session header
- `QuickStartPage.tsx` — no structural change needed; `TicketQueue` already renders at line 64. The existing component is updated in place (see Section 4).
### Shared Types (`types/tickets.ts`)
```typescript
export interface TicketFilters {
search: string;
board_id: number | null;
status_id: number | null;
priority: string | null;
company_id: number | null;
assigned: 'me' | 'unassigned' | 'all' | number; // number = specific member_id
include_closed: boolean;
}
export interface TicketCreationPayload {
summary: string;
company_id: number | null;
board_id: number | null;
status_id: number | null;
priority_id: number | null;
description: string;
assigned_member_id: number | null;
}
export interface AiParseResponse {
summary: string | null;
company_id: number | null;
board_id: number | null;
priority_id: number | null;
status_id: number | null;
assigned_member_id: number | null;
description: string | null;
missing_fields: string[];
warnings: string[];
}
export interface PSAResource {
member_id: number;
member_name: string;
member_identifier: string;
is_rf_user: boolean;
}
// TicketSearchResult is the existing PSATicketSearchResult type from types/integrations.ts
// Re-export or import from there — do not redefine
export interface TicketListResponse {
items: PSATicketSearchResult[];
total: number;
page: number;
page_size: number;
}
```
### TicketsPage — Filter & Pagination State
All filter and pagination state lives in URL query params via `useSearchParams`:
| Param | Type | Default |
|-------|------|---------|
| `search` | string | `""` |
| `board` | number | — |
| `status` | number | — |
| `priority` | string | — |
| `company` | number | — |
| `assigned` | `me \| unassigned \| all \| {id}` | `all` |
| `closed` | boolean | `false` |
| `page` | number | `1` |
Filter changes reset `page` to 1. Pagination: page size of 25. Controls show "Showing XY of Z tickets". Next disabled when `page * 25 >= total`.
### TicketFilterBar — Config-Driven
Filters defined as a `FILTER_CONFIG` array. Each entry:
```typescript
{ key: keyof TicketFilters, label: string, type: 'text' | 'select' | 'toggle', loadOptions?: () => Promise<Option[]> }
```
Adding or removing a filter is a one-line config change, not a component edit.
### TicketDetailPanel — Optimistic Hydration
The panel uses the **existing** `/integrations/psa/tickets/{id}/context` endpoint (client: `psaContextApi.getTicketContext()` in `frontend/src/api/psaContext.ts`) which already returns company, contact, configurations, notes, and related tickets in one call. This avoids creating redundant endpoints.
1. Panel opens immediately with list row data (id, summary, company, board, status, priority) — no loading state for these fields
2. Two parallel fetches fire on open:
- `psaContextApi.getTicketContext(ticketId)` — hydrates contact, notes, configs, related tickets
- `ticketsApi.listResources(ticketId)` — hydrates assignees (new endpoint)
3. All detail sections (contact, notes, configs, related) render skeletons until `getTicketContext` resolves
4. Resources section renders skeleton until `listResources` resolves
`get_ticket` (the simpler single-ticket endpoint) is **not** used by the panel — `getTicketContext` is a strict superset of the data needed.
### NewTicketModal — State Ownership
- `NewTicketModal` owns the `TicketCreationPayload` draft state
- `AiTicketParseForm` is a pure emitter: accepts a prompt string, calls `ai-parse`, fires `onParsed(Partial<TicketCreationPayload>)` upward
- Modal merges parsed values into draft, highlights `missing_fields` with visual indicators
- Two tabs: **Quick Create** (AI prompt → review) | **Full Form** (manual entry)
- Default tab: Quick Create if AI-triggered, Full Form if engineer-initiated
- Initial props: `initialValues?: Partial<TicketCreationPayload>` — used for spin-off pre-population
---
## Section 3 — ResolutionAssist Integration
### Two Trigger Paths
**1. AI-suggested (via `[ACTIONS]` marker)**
When the AI identifies a second distinct issue during a session, it emits a JSON array inside the `[ACTIONS]` marker — matching the exact format `_parse_actions_marker()` in `unified_chat_service.py` expects (a list of objects with `label`, `command`, `description`):
```
[ACTIONS]
[
{
"label": "Create ticket: Printer offline on 2nd floor",
"command": "create_spin_off_ticket",
"description": "Printer offline on 2nd floor"
}
]
[/ACTIONS]
```
The existing `_parse_actions_marker()` parser in `unified_chat_service.py` already handles this format — no parser changes needed. The frontend reads `action.command === "create_spin_off_ticket"` to render the "Create Ticket" button in TaskLane, and uses `action.description` as the `summary_hint` pre-populated into the Quick Create prompt input.
`summary_hint` (from `action.description`) populates the AI prompt input only, not the summary field directly. The engineer still runs the AI parse step and reviews all output. This prevents bypassing review with potentially hallucinated values.
**2. Engineer-initiated**
A "New Ticket" button in the ResolutionAssist session header. Always visible regardless of AI suggestion. Opens `NewTicketModal` with Full Form tab as default.
### Both Paths — NewTicketModal Pre-population
**The linked ticket IDs problem:** The current `PSATicketInfo` type in `frontend/src/types/integrations.ts` only exposes `company_name` and `board_name` — not `company_id` or `board_id`. The modal needs the numeric IDs to pre-populate the form selects.
**Fix:** Expand `PSATicketInfo` in `types/integrations.ts` to add the optional ID fields:
```typescript
export interface PSATicketInfo {
id: string
summary: string
company_name: string | null
board_name: string | null
status_name: string | null
priority_name: string | null
company_id: number | null // add
board_id: number | null // add
}
```
These fields are already returned by the CW API in `get_ticket()` — update `_map_ticket()` in `ConnectWiseProvider` and the `PSATicketInfo` Pydantic schema to pass them through.
**`AssistantChatPage` state change required:** The current page only tracks `activePsaTicketId: string | null` (line 76) — it does not hold a `PSATicketInfo` object. Add a new state field:
```typescript
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
```
When the modal is opened (either via AI suggestion or the "New Ticket" button), if `activePsaTicketId` is set and `linkedTicket` is null, fire `integrationsApi.getTicket(activePsaTicketId)` to fetch the full ticket (which now includes `company_id` and `board_id`) and store it in `linkedTicket`. The modal opens immediately — `initialValues` is populated once the fetch resolves and the form fields update. If the fetch is still in flight when the modal opens, `company_id` and `board_id` start empty and fill in when ready.
Once `linkedTicket` is populated, the modal receives:
```typescript
initialValues: {
company_id: linkedTicket.company_id,
board_id: linkedTicket.board_id,
}
```
When no linked ticket exists (`activePsaTicketId === null`): `initialValues` is omitted. `company_id` and `board_id` render empty, requiring manual selection. No silent defaults, no errors.
### TaskLane Action Lifecycle
- Opening the modal does **not** remove the action from TaskLane
- Dismissing the modal without submitting leaves the action visible
- Successful ticket creation removes the action and shows a success toast: `"Ticket #1042 created in ConnectWise"`
### System Prompt Addition
New rule added to `ASSISTANT_SYSTEM_PROMPT` in `backend/app/services/assistant_chat_service.py`:
> When you identify a second distinct issue that is clearly separate from the primary topic of this session, suggest creating a spin-off ticket using the `[ACTIONS]` marker. Use `"command": "create_spin_off_ticket"` and put the issue description in `"description"`. Only suggest this when the issue is genuinely separate — do not suggest for every tangential mention.
### Backend
- **`assistant_chat_service.py`** — system prompt updated with spin-off ticket instruction (above)
- **`unified_chat_service.py`** — no parser changes needed; the existing `_parse_actions_marker()` already handles the JSON array format. The frontend reads `command === "create_spin_off_ticket"` to route the action
- **`flowpilot_engine.py`** — no changes needed for this feature; guided FlowPilot sessions do not use this action type in the current scope
No new backend endpoints — the modal reuses `POST /integrations/psa/tickets` and `POST /integrations/psa/tickets/ai-parse`.
---
## Section 4 — Dashboard Widget (QuickStartPage)
### Placement
`TicketQueue` **already exists** in `QuickStartPage` (line 64, below `ActiveFlowPilotSessions`, above the Dashboard section). It currently auto-hides if no PSA connection exists. This spec updates the existing `TicketQueue` component — it is **not** a new widget and does not need to be added to `QuickStartPage`. The Dashboard section below it is not collapsible.
### Data Fetching
On mount: `GET /integrations/psa/member-mappings` first to detect mapping state, then `integrationsApi.searchTicketsQueue({ assigned_to_me: true, include_closed: false, page_size: 5 })` if a mapping exists for the current user.
`searchTicketsQueue` is used (not `searchTickets`) because it already accepts `assigned_to_me` and `page_size` params. Its return type will be updated to `TicketListResponse` as part of the search endpoint migration, so the widget reads `.items` after that change.
Member mapping detection is explicit — the widget checks the mappings response, not the ticket result. "No mapping" and "no tickets" are distinct states.
### Widget States
| State | Condition | Display |
|-------|-----------|---------|
| Hidden | No PSA connection | Widget not rendered |
| Prompt | PSA connected, no member mapping | "Map your PSA member to see your queue" → `/account/integrations` |
| Loading | Fetching | 3 skeleton rows |
| Populated | Tickets returned | Up to 5 compact rows + "View All Tickets →" |
| Empty | No assigned open tickets | "No open tickets assigned to you" — muted, no CTA |
| Error | PSA fetch fails | Silent — returns `[]`, no toast (per Lesson 111) |
### Row Display
Compact row matching Tickets page style: `#ID · Summary · Status badge · Priority dot`
Clicking a row opens `TicketDetailPanel` as a right-side sheet rendered at the `QuickStartPage` level. Does **not** navigate away.
### "View All Tickets" Link
Links to `/tickets?assigned=me`. `TicketsPage` reads `assigned` from `useSearchParams` on mount and applies it as the initial filter state — consistent with Section 2 URL param contract.
### Sorting
Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the CW API query. Widget does not sort client-side.
---
## Files Changed Summary
### New Backend Files
- `backend/app/services/ticket_service.py`
- `backend/app/schemas/psa_tickets.py`
### Modified Backend Files
- `backend/app/api/endpoints/integrations.py` — 6 new endpoints, update search to return `TicketListResponse`
- `backend/app/services/psa/types.py` — add `PaginatedTicketResult` dataclass
- `backend/app/services/psa/base.py` — 4 new abstract methods; update `search_tickets` return type to `PaginatedTicketResult`
- `backend/app/services/psa/connectwise/provider.py` — implement 4 new methods; update `search_tickets` to fire parallel count request and return `PaginatedTicketResult`; update `_map_ticket()` to pass through `company_id` and `board_id`
- `backend/app/schemas/psa_connection.py` — add `company_id` and `board_id` to `PSATicketInfo` Pydantic schema
- `backend/app/services/assistant_chat_service.py` — add spin-off ticket rule to `ASSISTANT_SYSTEM_PROMPT`
- ~~`backend/app/services/flowpilot_engine.py`~~ — no changes (FlowPilot out of scope for this feature)
- ~~`backend/app/services/unified_chat_service.py`~~ — no changes (existing `[ACTIONS]` parser handles the format)
### New Frontend Files
- `frontend/src/pages/TicketsPage.tsx`
- `frontend/src/api/tickets.ts`
- `frontend/src/types/tickets.ts`
- `frontend/src/components/tickets/TicketListRow.tsx`
- `frontend/src/components/tickets/TicketFilterBar.tsx`
- `frontend/src/components/tickets/TicketDetailPanel.tsx`
- `frontend/src/components/tickets/NewTicketModal.tsx`
- `frontend/src/components/tickets/AiTicketParseForm.tsx`
- `frontend/src/components/tickets/detail/TicketDetailHeader.tsx`
- `frontend/src/components/tickets/detail/TicketResourceManager.tsx`
- `frontend/src/components/tickets/detail/TicketNotesFeed.tsx`
- `frontend/src/components/tickets/detail/TicketAddNote.tsx`
- `frontend/src/components/tickets/detail/TicketConfigs.tsx`
- `frontend/src/components/tickets/detail/TicketRelated.tsx`
### Modified Frontend Files
- `frontend/src/router.tsx``/tickets` route
- `frontend/src/components/layout/AppLayout.tsx` — Tickets nav item
- `frontend/src/pages/AssistantChatPage.tsx` — handle `create_spin_off_ticket` command in action renderer + add "New Ticket" button to session header
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component (see Section 4 — not a new file)
- `frontend/src/api/integrations.ts` — update `searchTickets()` and `searchTicketsQueue()` return types to `TicketListResponse`
- `frontend/src/types/integrations.ts` — add `company_id: number | null` and `board_id: number | null` to `PSATicketInfo`
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component: read `.items`, add mapping-state detection, member-mapping check, and 5-item cap
- `frontend/src/components/session/TicketPickerModal.tsx` — read `.items` from paginated response
---
## Out of Scope
- Autotask provider implementation (schema-ready, not implemented)
- Time entry creation from ticket detail (provider method exists, no UI)
- Ticket editing beyond status (summary, description, priority changes)
- Bulk ticket operations
- Real-time ticket updates / polling

View File

@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"immer": "^11.1.3", "immer": "^11.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
@@ -5331,6 +5332,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",

View File

@@ -36,6 +36,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"immer": "^11.1.3", "immer": "^11.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

View File

@@ -2,6 +2,11 @@ import api from './client'
import type { import type {
DashboardMetrics, DashboardMetrics,
ActivityEntry, ActivityEntry,
AdminUserListResponse,
AdminAccountListResponse,
AdminAccountDetailResponse,
AdminAccountCreate,
AdminAccountUpdate,
AuditLogListResponse, AuditLogListResponse,
PlanLimitConfig, PlanLimitConfig,
AccountOverrideResponse, AccountOverrideResponse,
@@ -78,7 +83,15 @@ export const adminApi = {
createUser: (data: AdminUserCreate) => createUser: (data: AdminUserCreate) =>
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data), api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
listUsers: (params?: Record<string, unknown>) => listUsers: (params?: Record<string, unknown>) =>
api.get('/admin/users', { params }).then(r => r.data), api.get<AdminUserListResponse>('/admin/users', { params }).then(r => r.data),
listAccounts: (params?: Record<string, unknown>) =>
api.get<AdminAccountListResponse>('/admin/accounts', { params }).then(r => r.data),
createAccount: (data: AdminAccountCreate) =>
api.post<AdminAccountDetailResponse>('/admin/accounts', data).then(r => r.data),
getAccountDetail: (id: string, params?: Record<string, unknown>) =>
api.get<AdminAccountDetailResponse>(`/admin/accounts/${id}`, { params }).then(r => r.data),
updateAccount: (id: string, data: AdminAccountUpdate) =>
api.put<AdminAccountDetailResponse>(`/admin/accounts/${id}`, data).then(r => r.data),
getUser: (id: string) => getUser: (id: string) =>
api.get(`/admin/users/${id}`).then(r => r.data), api.get(`/admin/users/${id}`).then(r => r.data),
updateUserRole: (id: string, role: string) => updateUserRole: (id: string, role: string) =>
@@ -119,6 +132,10 @@ export const adminApi = {
api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data), api.put(`/admin/users/${id}/subscription/plan`, { plan }).then(r => r.data),
extendUserTrial: (id: string, days: number) => extendUserTrial: (id: string, days: number) =>
api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data), api.put(`/admin/users/${id}/subscription/extend-trial`, { days }).then(r => r.data),
updateAccountSubscriptionPlan: (id: string, plan: string) =>
api.put(`/admin/accounts/${id}/subscription/plan`, { plan }).then(r => r.data),
extendAccountTrial: (id: string, days: number) =>
api.put(`/admin/accounts/${id}/subscription/extend-trial`, { days }).then(r => r.data),
// Invite Codes // Invite Codes
listInviteCodes: (params?: Record<string, unknown>) => listInviteCodes: (params?: Record<string, unknown>) =>

Some files were not shown because too many files have changed in this diff Show More